blob: 7d4856b8948a26abc6a2be39f718e8f67ca0f912 [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 <list>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "base/containers/adapters.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 "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), uuid_to_match_map_(FindGuidMatches(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::MoveAndMerge() {
// 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);
}
// Clear the UUID match map to avoid dangling pointers.
uuid_to_match_map_.clear();
// All local nodes have been copied to account storage and can be safely
// removed.
for (const auto& [local_permanent_node, unused] :
GetLocalAndAccountPermanentNodePairs(model_)) {
CHECK(local_permanent_node->children().empty());
}
model_->EndExtensiveChanges();
}
// static
std::unordered_map<base::Uuid,
LocalBookmarkToAccountMerger::GuidMatch,
base::UuidHash>
LocalBookmarkToAccountMerger::FindGuidMatches(
const bookmarks::BookmarkModel* model) {
CHECK(model);
CHECK(model->loaded());
std::unordered_map<base::Uuid, LocalBookmarkToAccountMerger::GuidMatch,
base::UuidHash>
uuid_to_match_map;
// Iterate through all local bookmarks to find matches by UUID.
for (const auto& [local_permanent_node, unused] :
GetLocalAndAccountPermanentNodePairs(model)) {
CHECK(local_permanent_node);
ui::TreeNodeIterator<const bookmarks::BookmarkNode> local_iterator(
local_permanent_node);
while (local_iterator.has_next()) {
const bookmarks::BookmarkNode* const local_node = local_iterator.Next();
CHECK(local_node->uuid().is_valid());
const bookmarks::BookmarkNode* const account_node = model->GetNodeByUuid(
local_node->uuid(),
bookmarks::BookmarkModel::NodeTypeForUuidLookup::kAccountNodes);
if (!account_node) {
// No match found by UUID.
continue;
}
if (NodesCompatibleForMatchByUuid(account_node, local_node)) {
const bool success = uuid_to_match_map
.emplace(account_node->uuid(),
GuidMatch{local_node, account_node})
.second;
CHECK(success);
}
}
}
return uuid_to_match_map;
}
void LocalBookmarkToAccountMerger::RemoveChildrenAt(
const bookmarks::BookmarkNode* parent,
const std::vector<size_t>& indices_to_remove,
const base::Location& location) {
CHECK(parent);
// TODO(crbug.com/332532186): This has quadratic runtime complexity and should
// be improved.
for (size_t index : base::Reversed(indices_to_remove)) {
const bookmarks::BookmarkNode* child = parent->children().at(index).get();
// Remove the UUID from the map to avoid dangling pointers.
uuid_to_match_map_.erase(child->uuid());
model_->Remove(child, kEditSourceForMetrics, location);
}
}
void LocalBookmarkToAccountMerger::MoveOrMergeDescendants(
const bookmarks::BookmarkNode* local_subtree_root,
const bookmarks::BookmarkNode* account_subtree_root) {
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);
}
// 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());
// 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 subtrees should be merged as well.
MoveOrMergeDescendants(local_child, matching_account_node);
++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).
MergeAndDeleteDescendantsThatMatchByUuid(local_child);
// Move the local node to account storage, along with all remaining
// descendants that didn't match by UUID.
// TODO(crbug.com/332532186): This has quadratic runtime complexity and
// should be improved.
model_->Move(local_child, account_subtree_root,
account_subtree_root->children().size());
}
}
// All remaining local nodes must have found a matching account node and been
// merged into it, as nodes without a match have been moved. Therefore, the
// remaining local data can be safely deleted.
while (!local_subtree_root->children().empty()) {
// Update the UUID match map to avoid dangling pointers.
uuid_to_match_map_.erase(local_subtree_root->children().back()->uuid());
model_->RemoveLastChild(local_subtree_root, kEditSourceForMetrics,
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);
} else {
// Continue recursively to look for UUID-based matches.
MergeAndDeleteDescendantsThatMatchByUuid(local_child);
}
}
RemoveChildrenAt(local_subtree_root, 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 auto it = uuid_to_match_map_.find(account_node->uuid());
if (it == uuid_to_match_map_.end()) {
return nullptr;
}
const bookmarks::BookmarkNode* local_node = it->second.local_node;
CHECK(local_node);
CHECK_EQ(it->second.account_node, account_node);
CHECK(NodesCompatibleForMatchByUuid(local_node, account_node));
return local_node;
}
const bookmarks::BookmarkNode*
LocalBookmarkToAccountMerger::FindMatchingAccountNodeByUuid(
const bookmarks::BookmarkNode* local_node) const {
CHECK(local_node);
const auto it = uuid_to_match_map_.find(local_node->uuid());
if (it == uuid_to_match_map_.end()) {
return nullptr;
}
const bookmarks::BookmarkNode* account_node = it->second.account_node;
CHECK(account_node);
CHECK_EQ(it->second.local_node, local_node);
CHECK(NodesCompatibleForMatchByUuid(local_node, account_node));
return account_node;
}
} // namespace sync_bookmarks