chromium/chrome/browser/ui/webui/ash/settings/search/search_handler.cc

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/ui/webui/ash/settings/search/search_handler.h"

#include "base/ranges/algorithm.h"
#include "base/strings/string_number_conversions.h"
#include "chrome/browser/ui/webui/ash/settings/pages/os_settings_sections.h"
#include "chrome/browser/ui/webui/ash/settings/search/hierarchy.h"
#include "chrome/browser/ui/webui/ash/settings/search/mojom/search_result_icon.mojom.h"
#include "chrome/browser/ui/webui/ash/settings/search/search_concept.h"
#include "chrome/grit/generated_resources.h"
#include "chromeos/ash/components/local_search_service/public/cpp/local_search_service_proxy.h"
#include "ui/base/l10n/l10n_util.h"

namespace ash::settings {

namespace mojom {
using ::chromeos::settings::mojom::Section;
using ::chromeos::settings::mojom::Setting;
using ::chromeos::settings::mojom::Subpage;
}  // namespace mojom

namespace {

bool ContainsSectionResult(const std::vector<mojom::SearchResultPtr>& results,
                           mojom::Section section) {
  return base::ranges::any_of(results, [section](const auto& result) {
    return result->type == mojom::SearchResultType::kSection &&
           section == result->id->get_section();
  });
}

bool ContainsSubpageResult(const std::vector<mojom::SearchResultPtr>& results,
                           mojom::Subpage subpage) {
  return base::ranges::any_of(results, [subpage](const auto& result) {
    return result->type == mojom::SearchResultType::kSubpage &&
           subpage == result->id->get_subpage();
  });
}

}  // namespace

SearchHandler::SearchHandler(
    SearchTagRegistry* search_tag_registry,
    OsSettingsSections* sections,
    Hierarchy* hierarchy,
    local_search_service::LocalSearchServiceProxy* local_search_service_proxy)
    : search_tag_registry_(search_tag_registry),
      sections_(sections),
      hierarchy_(hierarchy) {
  local_search_service_proxy->GetIndex(
      local_search_service::IndexId::kCrosSettings,
      local_search_service::Backend::kLinearMap,
      index_remote_.BindNewPipeAndPassReceiver());
  DCHECK(index_remote_.is_bound());

  search_tag_registry_->AddObserver(this);
}

SearchHandler::~SearchHandler() {
  search_tag_registry_->RemoveObserver(this);
}

void SearchHandler::BindInterface(
    mojo::PendingReceiver<mojom::SearchHandler> pending_receiver) {
  receivers_.Add(this, std::move(pending_receiver));
}

void SearchHandler::Search(const std::u16string& query,
                           uint32_t max_num_results,
                           mojom::ParentResultBehavior parent_result_behavior,
                           SearchCallback callback) {
  // Search for 5x the maximum set of results. If there are many matches for
  // a query, it may be the case that |index_| returns some matches with higher
  // SearchResultDefaultRank values later in the list. Requesting up to 5x the
  // maximum number ensures that such results will be returned and can be ranked
  // accordingly when sorted.
  uint32_t max_local_search_service_results = 5 * max_num_results;

  index_remote_->Find(
      query, max_local_search_service_results,
      base::BindOnce(&SearchHandler::OnFindComplete,
                     weak_ptr_factory_.GetWeakPtr(), std::move(callback),
                     max_num_results, parent_result_behavior));
}

void SearchHandler::Observe(
    mojo::PendingRemote<mojom::SearchResultsObserver> observer) {
  observers_.Add(std::move(observer));
}

void SearchHandler::OnRegistryUpdated() {
  for (auto& observer : observers_) {
    observer->OnSearchResultsChanged();
  }
}

std::vector<mojom::SearchResultPtr> SearchHandler::GenerateSearchResultsArray(
    const std::vector<local_search_service::Result>&
        local_search_service_results,
    uint32_t max_num_results,
    mojom::ParentResultBehavior parent_result_behavior) const {
  std::vector<mojom::SearchResultPtr> search_results;
  for (const auto& result : local_search_service_results) {
    mojom::SearchResultPtr result_ptr = ResultToSearchResult(result);
    if (result_ptr) {
      search_results.push_back(std::move(result_ptr));
    }
  }

  std::sort(search_results.begin(), search_results.end(), CompareSearchResults);

  // Now that the results have been sorted, limit the size of to
  // |max_num_results|.
  search_results.resize(
      std::min(static_cast<size_t>(max_num_results), search_results.size()));

  if (parent_result_behavior ==
      mojom::ParentResultBehavior::kAllowParentResults) {
    AddParentResults(max_num_results, &search_results);
  }

  return search_results;
}

void SearchHandler::OnFindComplete(
    SearchCallback callback,
    uint32_t max_num_results,
    mojom::ParentResultBehavior parent_result_behavior,
    local_search_service::ResponseStatus response_status,
    const std::optional<std::vector<local_search_service::Result>>&
        local_search_service_results) {
  if (response_status != local_search_service::ResponseStatus::kSuccess) {
    LOG(ERROR) << "Cannot search; LocalSearchService returned "
               << static_cast<int>(response_status)
               << ". Returning empty results array.";
    std::move(callback).Run({});
    return;
  }

  std::move(callback).Run(
      GenerateSearchResultsArray(local_search_service_results.value(),
                                 max_num_results, parent_result_behavior));
}

void SearchHandler::AddParentResults(
    uint32_t max_num_results,
    std::vector<mojom::SearchResultPtr>* search_results) const {
  auto it = search_results->begin();
  while (search_results->size() < max_num_results &&
         it != search_results->end()) {
    const mojom::SearchResultPtr& result = *it;
    switch (result->type) {
      case mojom::SearchResultType::kSection:
        // Sections have no parents; nothing to do.
        break;

      case mojom::SearchResultType::kSubpage: {
        const Hierarchy::SubpageMetadata& metadata =
            hierarchy_->GetSubpageMetadata(result->id->get_subpage());

        // Nested subpage.
        if (metadata.parent_subpage) {
          it = AddSubpageResultIfPossible(it, result, *metadata.parent_subpage,
                                          result->relevance_score,
                                          search_results);
          break;
        }

        // Top-level subpage.
        it = AddSectionResultIfPossible(it, result, metadata.section,
                                        search_results);
        break;
      }

      case mojom::SearchResultType::kSetting: {
        const Hierarchy::SettingMetadata& metadata =
            hierarchy_->GetSettingMetadata(result->id->get_setting());

        // Nested setting.
        if (metadata.primary.subpage) {
          it = AddSubpageResultIfPossible(it, result, *metadata.primary.subpage,
                                          result->relevance_score,
                                          search_results);
          break;
        }

        // Top-level setting.
        it = AddSectionResultIfPossible(it, result, metadata.primary.section,
                                        search_results);
        break;
      }
    }

    ++it;
  }
}

std::vector<mojom::SearchResultPtr>::iterator
SearchHandler::AddSectionResultIfPossible(
    const std::vector<mojom::SearchResultPtr>::iterator& curr_position,
    const mojom::SearchResultPtr& child_result,
    mojom::Section section,
    std::vector<mojom::SearchResultPtr>* results) const {
  // If |results| already includes |section|, do not add it again.
  if (ContainsSectionResult(*results, section)) {
    return curr_position;
  }

  mojom::SearchResultPtr section_result =
      hierarchy_->GetSectionMetadata(section).ToSearchResult(
          child_result->relevance_score);

  // Don't add a result for a parent section if it has the exact same text as
  // the child result, since this results in a broken-looking UI.
  if (section_result->text == child_result->text) {
    return curr_position;
  }

  return results->insert(curr_position + 1, std::move(section_result));
}

std::vector<mojom::SearchResultPtr>::iterator
SearchHandler::AddSubpageResultIfPossible(
    const std::vector<mojom::SearchResultPtr>::iterator& curr_position,
    const mojom::SearchResultPtr& child_result,
    mojom::Subpage subpage,
    double relevance_score,
    std::vector<mojom::SearchResultPtr>* results) const {
  // If |results| already includes |subpage|, do not add it again.
  if (ContainsSubpageResult(*results, subpage)) {
    return curr_position;
  }

  mojom::SearchResultPtr subpage_result =
      hierarchy_->GetSubpageMetadata(subpage).ToSearchResult(
          child_result->relevance_score);

  // Don't add a result for a parent subpage if it has the exact same text as
  // the child result, since this results in a broken-looking UI.
  if (subpage_result->text == child_result->text) {
    return curr_position;
  }

  return results->insert(
      curr_position + 1,
      hierarchy_->GetSubpageMetadata(subpage).ToSearchResult(relevance_score));
}

mojom::SearchResultPtr SearchHandler::ResultToSearchResult(
    const local_search_service::Result& result) const {
  const SearchConcept* search_concept =
      search_tag_registry_->GetTagMetadata(result.id);

  // If the concept was not registered, no metadata is available. This can occur
  // if the search tag was dynamically unregistered during the asynchronous
  // Find() call.
  if (!search_concept) {
    return nullptr;
  }

  // |result| is expected to have one position, whose ID is a stringified int.
  DCHECK_EQ(1u, result.positions.size());
  int content_id;
  if (!base::StringToInt(result.positions[0].content_id, &content_id)) {
    return nullptr;
  }

  std::string url;
  mojom::SearchResultIdentifierPtr result_id;
  std::vector<std::u16string> hierarchy_strings;
  switch (search_concept->type) {
    case mojom::SearchResultType::kSection: {
      mojom::Section section = search_concept->id.section;
      url = GetModifiedUrl(*search_concept, section);
      result_id = mojom::SearchResultIdentifier::NewSection(section);
      hierarchy_strings.push_back(
          l10n_util::GetStringUTF16(IDS_INTERNAL_APP_SETTINGS));
      break;
    }
    case mojom::SearchResultType::kSubpage: {
      mojom::Subpage subpage = search_concept->id.subpage;
      url = GetModifiedUrl(*search_concept,
                           hierarchy_->GetSubpageMetadata(subpage).section);
      result_id = mojom::SearchResultIdentifier::NewSubpage(subpage);
      hierarchy_strings = hierarchy_->GenerateAncestorHierarchyStrings(subpage);
      break;
    }
    case mojom::SearchResultType::kSetting: {
      mojom::Setting setting = search_concept->id.setting;
      url = GetModifiedUrl(
          *search_concept,
          hierarchy_->GetSettingMetadata(setting).primary.section);
      result_id = mojom::SearchResultIdentifier::NewSetting(setting);
      hierarchy_strings = hierarchy_->GenerateAncestorHierarchyStrings(setting);
      break;
    }
  }

  return mojom::SearchResult::New(
      /*text=*/l10n_util::GetStringUTF16(content_id),
      /*canonical_text=*/
      l10n_util::GetStringUTF16(search_concept->canonical_message_id), url,
      search_concept->icon, result.score, hierarchy_strings,
      search_concept->default_rank,
      /*was_generated_from_text_match=*/true, search_concept->type,
      std::move(result_id));
}

std::string SearchHandler::GetModifiedUrl(const SearchConcept& search_concept,
                                          mojom::Section section) const {
  return sections_->GetSection(section)->ModifySearchResultUrl(
      search_concept.type, search_concept.id,
      search_concept.url_path_with_parameters);
}

// static
bool SearchHandler::CompareSearchResults(const mojom::SearchResultPtr& first,
                                         const mojom::SearchResultPtr& second) {
  // Compute the difference between the results' default rankings. Note that
  // kHigh is declared before kMedium which is declared before kLow, so a
  // negative value indicates that |first| is ranked higher than |second| and a
  // positive value indicates that |second| is ranked higher than |first|.
  int32_t default_rank_diff = static_cast<int32_t>(first->default_rank) -
                              static_cast<int32_t>(second->default_rank);
  if (default_rank_diff < 0) {
    return true;
  }
  if (default_rank_diff > 0) {
    return false;
  }

  // At this point, the default ranks are equal, so compare relevance scores. A
  // higher relevance score indicates a better text match, so the reverse is
  // true this time.
  if (first->relevance_score > second->relevance_score) {
    return true;
  }
  if (first->relevance_score < second->relevance_score) {
    return false;
  }

  // Default rank and relevance scores are equal, so prefer the result which is
  // higher on the hierarchy. kSection is declared before kSubpage which is
  // declared before kSetting, so follow the same pattern from default ranks
  // above. Note that if the types are equal, this will return false, which
  // induces a strict weak ordering.
  return static_cast<int32_t>(first->type) < static_cast<int32_t>(second->type);
}

}  // namespace ash::settings