blob: 687c2ad11112dea4e7c17f4d63d20cff206bc76e [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/sync_bookmarks/local_bookmark_to_account_merger.h"
#include <algorithm>
#include <cstddef>
#include <list>
#include <optional>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include "base/containers/adapters.h"
#include "base/containers/flat_set.h"
#include "base/feature_list.h"
#include "base/hash/hash.h"
#include "base/strings/utf_string_conversions.h"
#include "base/uuid.h"
#include "components/bookmarks/browser/bookmark_model.h"
#include "components/bookmarks/browser/bookmark_node.h"
#include "components/sync_bookmarks/bookmark_model_view.h"
#include "components/sync_bookmarks/bookmark_specifics_conversions.h"
#include "components/sync_bookmarks/switches.h"
#include "ui/base/models/tree_node_iterator.h"
#include "url/gurl.h"
namespace sync_bookmarks {
namespace {
constexpr bookmarks::metrics::BookmarkEditSource kEditSourceForMetrics =
bookmarks::metrics::BookmarkEditSource::kOther;
// Struct representing a subset of fields of BookmarkNode, such that two nodes
// with the same parent are considered a semantic match if the
// SiblingSemanticMatchKey value computed for them are equal.
struct SiblingSemanticMatchKey {
// Bookmarked URL or nullopt for folders. This also means a URL node never
// matches semantically with a folder.
std::optional<GURL> url;
// Title equality is required, but the fact that Sync used to truncate the
// title to a maximum size is incorporated here (i.e. the truncated title is
// represented here).
std::string canonicalized_sync_title;
};
struct SiblingSemanticMatchKeyHash {
size_t operator()(const SiblingSemanticMatchKey& key) const {
return base::FastHash(key.canonicalized_sync_title) ^
(key.url.has_value()
? base::FastHash(key.url->possibly_invalid_spec())
: size_t(1));
}
};
struct SiblingSemanticMatchKeyEquals {
size_t operator()(const SiblingSemanticMatchKey& lhs,
const SiblingSemanticMatchKey& rhs) const {
return lhs.url == rhs.url &&
lhs.canonicalized_sync_title == rhs.canonicalized_sync_title;
}
};
SiblingSemanticMatchKey GetSiblingSemanticMatchKeyForNode(
const bookmarks::BookmarkNode* node) {
SiblingSemanticMatchKey key;
if (node->is_url()) {
key.url = node->url();
}
key.canonicalized_sync_title =
sync_bookmarks::FullTitleToLegacyCanonicalizedTitle(
base::UTF16ToUTF8(node->GetTitle()));
return key;
}
bool NodesCompatibleForMatchByUuid(const bookmarks::BookmarkNode* node1,
const bookmarks::BookmarkNode* node2) {
CHECK_EQ(node1->uuid(), node2->uuid());
if (node1->is_folder() != node2->is_folder()) {
return false;
}
if (!node2->is_folder() && node1->url() != node2->url()) {
return false;
}
// Note that the title isn't required to be equal, which also means that two
// folders don't have additional requirements, if their UUIDs are equal.
return true;
}
// Returns a vector with all user-editable permanent nodes, grouped in pairs
// where the first element is the local permanent node and the second one is
// the account counterpart.
std::vector<std::pair<raw_ptr<const bookmarks::BookmarkNode>,
raw_ptr<const bookmarks::BookmarkNode>>>
GetLocalAndAccountPermanentNodePairs(const bookmarks::BookmarkModel* model) {
CHECK(model);
return {{model->bookmark_bar_node(), model->account_bookmark_bar_node()},
{model->other_node(), model->account_other_node()},
{model->mobile_node(), model->account_mobile_node()}};
}
} // namespace
LocalBookmarkToAccountMerger::LocalBookmarkToAccountMerger(
bookmarks::BookmarkModel* model)
: model_(model) {
CHECK(model_);
CHECK(model_->loaded());
for (const auto& [local_permanent_node, account_permanent_node] :
GetLocalAndAccountPermanentNodePairs(model)) {
CHECK(local_permanent_node);
CHECK(account_permanent_node);
}
}
LocalBookmarkToAccountMerger::~LocalBookmarkToAccountMerger() = default;
void LocalBookmarkToAccountMerger::MoveAndMergeAllNodes() {
MoveAndMergeInternal(/*child_ids_to_merge=*/std::nullopt);
}
void LocalBookmarkToAccountMerger::MoveAndMergeSpecificSubtrees(
std::set<int64_t> permanent_folder_child_ids_to_merge) {
MoveAndMergeInternal(std::move(permanent_folder_child_ids_to_merge));
}
void LocalBookmarkToAccountMerger::MoveAndMergeInternal(
std::optional<std::set<int64_t>> permanent_folder_child_ids_to_merge) {
// Notify UI intensive observers of BookmarkModel that we are about to make
// potentially significant changes to it, so the updates may be batched. For
// example, on Mac, the bookmarks bar displays animations when bookmark items
// are added or deleted.
model_->BeginExtensiveChanges();
// Algorithm description:
// Match up the roots and recursively do the following:
// * For each local node for the current local parent node, either
// find an account node with equal UUID anywhere throughout the tree or find
// the best matching bookmark node under the corresponding account bookmark
// parent node using semantics. If the found node has the same UUID as a
// different local bookmark, it is not considered a semantics match, as
// UUID matching takes precedence.
// * If no matching node is found, move the local node to account storage by
// appending it last.
// * If a matching node is found, update the properties of it from the
// corresponding local node.
//
// The semantics best match algorithm uses folder title or bookmark title/url
// to perform the primary match. If there are multiple match candidates it
// selects the first one.
for (const auto& [local_permanent_node, account_permanent_node] :
GetLocalAndAccountPermanentNodePairs(model_)) {
CHECK(local_permanent_node);
CHECK(account_permanent_node);
MoveOrMergeDescendants(
/*local_subtree_root=*/local_permanent_node,
/*account_subtree_root=*/account_permanent_node,
permanent_folder_child_ids_to_merge);
}
// Any remaining local node may only be explained because
// `permanent_folder_child_ids_to_merge` is non-empty and excluded such node.
for (const auto& [local_permanent_node, unused] :
GetLocalAndAccountPermanentNodePairs(model_)) {
CHECK(std::ranges::all_of(
local_permanent_node->children(),
[&permanent_folder_child_ids_to_merge](const auto& child) {
return permanent_folder_child_ids_to_merge.has_value() &&
!permanent_folder_child_ids_to_merge->contains(child->id());
}));
}
model_->EndExtensiveChanges();
}
void LocalBookmarkToAccountMerger::RemoveChildrenAtForTesting(
const bookmarks::BookmarkNode* parent,
const std::vector<size_t>& indices_to_remove,
const base::Location& location) {
RemoveChildrenAt(parent, indices_to_remove, location);
}
void LocalBookmarkToAccountMerger::RemoveChildrenAt(
const bookmarks::BookmarkNode* parent,
const base::flat_set<size_t>& indices_to_remove,
const base::Location& location) {
CHECK(parent);
if (indices_to_remove.empty()) {
// Nothing to do.
return;
}
const size_t num_children = parent->children().size();
CHECK_LT(*indices_to_remove.rbegin(), num_children);
// The single-removal case is trivial and needs no sophisticated optimization.
if (indices_to_remove.size() == 1u) {
const size_t index = *indices_to_remove.begin();
const bookmarks::BookmarkNode* child = parent->children().at(index).get();
model_->Remove(child, kEditSourceForMetrics, location);
return;
}
// Removal of children one by one, even in reverse order, can theoretically
// lead to quadratic behavior. However, A/B experiments via variations led to
// the conclusion that this simple implementation isn't slower than more
// sophisticated variants, even at high percentiles.
for (size_t index : base::Reversed(indices_to_remove)) {
const bookmarks::BookmarkNode* child = parent->children().at(index).get();
model_->Remove(child, kEditSourceForMetrics, location);
}
}
void LocalBookmarkToAccountMerger::MoveOrMergeDescendants(
const bookmarks::BookmarkNode* local_subtree_root,
const bookmarks::BookmarkNode* account_subtree_root,
std::optional<std::set<int64_t>> local_child_ids_to_merge) {
CHECK(local_subtree_root);
CHECK(account_subtree_root);
CHECK_EQ(account_subtree_root->is_folder(), local_subtree_root->is_folder());
CHECK_EQ(account_subtree_root->is_permanent_node(),
local_subtree_root->is_permanent_node());
// Build a lookup table containing account nodes that might be matched by
// semantics.
std::unordered_map<SiblingSemanticMatchKey,
std::list<const bookmarks::BookmarkNode*>,
SiblingSemanticMatchKeyHash, SiblingSemanticMatchKeyEquals>
account_node_candidates_for_semantic_match;
for (const auto& account_child_ptr : account_subtree_root->children()) {
const bookmarks::BookmarkNode* const account_child =
account_child_ptr.get();
// If a UUID match exists, it takes precedence over semantic matching.
if (FindMatchingLocalNodeByUuid(account_child)) {
continue;
}
// Permanent nodes must have matched by UUID.
CHECK(!account_child->is_permanent_node());
// Register the candidate while maintaining the original order.
account_node_candidates_for_semantic_match
[GetSiblingSemanticMatchKeyForNode(account_child)]
.push_back(account_child);
}
// A list of indices of local children that need to be deleted. These are
// populated in the loop below, and are correct for local_subtree_root
// *after* the `Move()` operations from that loop, that is.
std::vector<size_t> local_indices_to_remove;
local_indices_to_remove.reserve(local_subtree_root->children().size());
// If there are local child nodes, try to match them with account nodes.
size_t i = 0;
while (i < local_subtree_root->children().size()) {
const bookmarks::BookmarkNode* const local_child =
local_subtree_root->children()[i].get();
CHECK(!local_child->is_permanent_node());
if (local_child_ids_to_merge.has_value() &&
!local_child_ids_to_merge->contains(local_child->id())) {
// Skip this child if it's not in the list of IDs to merge.
//
// It's guaranteed that no ancestors of this node were selected as part of
// the move operation (if they were then the recursive call into this
// method would have `local_child_ids_to_merge` set to `nullopt`).
++i;
continue;
}
// Try to match by UUID first.
const bookmarks::BookmarkNode* matching_account_node =
FindMatchingAccountNodeByUuid(local_child);
if (!matching_account_node) {
auto it = account_node_candidates_for_semantic_match.find(
GetSiblingSemanticMatchKeyForNode(local_child));
if (it != account_node_candidates_for_semantic_match.end() &&
!it->second.empty()) {
// Semantic match found.
matching_account_node = it->second.front();
// Avoid that the same candidate would match again.
it->second.pop_front();
}
}
if (matching_account_node) {
// If a match was found, update the title and possible other fields.
CHECK(!matching_account_node->is_permanent_node());
UpdateAccountNodeFromMatchingLocalNode(local_child,
matching_account_node);
// Since nodes are matching, their full subtrees should be merged as well.
//
// Select all descendants for merging, as we don't have any use cases for
// selecting the parent but only a subset of its descendants.
MoveOrMergeDescendants(local_child, matching_account_node,
/*local_child_ids_to_merge=*/std::nullopt);
// The node has been merged to account storage, so remove it from the
// local parent.
//
// As a performance optimization, flag the node for removal at the end of
// the loop, so that the removal can be done in bulk.
local_indices_to_remove.push_back(i++);
} else {
// Before the entire local subtree is moved to account storage, iterate
// descendants to find UUID matches. This is necessary because UUID-based
// matches takes precedence over any ancestor having matched (by UUID or
// otherwise).
//
// Similarly to the recursive call to `MoveOrMergeDescendants` above,
// select all descendents for UUID merging/deletion, as this API doesn't
// support selecting a parent and just a subset of its descendants.
MergeAndDeleteDescendantsThatMatchByUuid(local_child);
// Move the local node to account storage, along with all remaining
// descendants that didn't match by UUID. Note that this can theoretically
// lead to quadratic runtime complexity, but measurements with a large
// number of bookmarks suggest the issue is not severe in practice and the
// complexity of the improved algorithm is not worth the maintenance cost.
model_->Move(local_child, account_subtree_root,
account_subtree_root->children().size());
}
}
// Remove the local nodes that were flagged for removal above.
RemoveChildrenAt(
local_subtree_root,
base::flat_set<size_t>(base::sorted_unique, local_indices_to_remove),
FROM_HERE);
}
void LocalBookmarkToAccountMerger::MergeAndDeleteDescendantsThatMatchByUuid(
const bookmarks::BookmarkNode* local_subtree_root) {
CHECK(local_subtree_root);
std::vector<size_t> indices_to_remove;
for (size_t i = 0; i < local_subtree_root->children().size(); ++i) {
const bookmarks::BookmarkNode* const local_child =
local_subtree_root->children()[i].get();
CHECK(!local_child->is_permanent_node());
if (const bookmarks::BookmarkNode* matching_account_node =
FindMatchingAccountNodeByUuid(local_child)) {
CHECK(!matching_account_node->is_permanent_node());
UpdateAccountNodeFromMatchingLocalNode(local_child,
matching_account_node);
indices_to_remove.push_back(i);
// Since nodes are matching, their subtrees should be merged as well. In
// this case the matching isn't restricted to UUID-based matching.
MoveOrMergeDescendants(local_child, matching_account_node,
/*local_child_ids_to_merge=*/std::nullopt);
} else {
// Continue recursively to look for UUID-based matches.
MergeAndDeleteDescendantsThatMatchByUuid(local_child);
}
}
RemoveChildrenAt(
local_subtree_root,
base::flat_set<size_t>(base::sorted_unique, indices_to_remove),
FROM_HERE);
}
void LocalBookmarkToAccountMerger::UpdateAccountNodeFromMatchingLocalNode(
const bookmarks::BookmarkNode* local_node,
const bookmarks::BookmarkNode* account_node) {
CHECK(local_node);
CHECK(account_node);
CHECK(!local_node->is_permanent_node());
CHECK(!account_node->is_permanent_node());
// Update all fields, where no-op changes are handled well.
// The meta-info map is intentionally excluded, since the desired behavior is
// unclear.
if (local_node->date_last_used() > account_node->date_last_used()) {
model_->UpdateLastUsedTime(account_node, local_node->date_last_used(),
/*just_opened=*/false);
}
// For the title, use the local one.
model_->SetTitle(account_node, local_node->GetTitle(), kEditSourceForMetrics);
}
const bookmarks::BookmarkNode*
LocalBookmarkToAccountMerger::FindMatchingLocalNodeByUuid(
const bookmarks::BookmarkNode* account_node) const {
CHECK(account_node);
const bookmarks::BookmarkNode* const local_node = model_->GetNodeByUuid(
account_node->uuid(),
bookmarks::BookmarkModel::NodeTypeForUuidLookup::kLocalOrSyncableNodes);
if (local_node && NodesCompatibleForMatchByUuid(local_node, account_node) &&
!model_->client()->IsNodeManaged(local_node)) {
return local_node;
}
return nullptr;
}
const bookmarks::BookmarkNode*
LocalBookmarkToAccountMerger::FindMatchingAccountNodeByUuid(
const bookmarks::BookmarkNode* local_node) const {
CHECK(local_node);
const bookmarks::BookmarkNode* const account_node = model_->GetNodeByUuid(
local_node->uuid(),
bookmarks::BookmarkModel::NodeTypeForUuidLookup::kAccountNodes);
if (account_node && NodesCompatibleForMatchByUuid(local_node, account_node)) {
return account_node;
}
return nullptr;
}
} // namespace sync_bookmarks