Files
hrynco-kswitcher/src/sync_merge.inl
T

192 lines
9.1 KiB
C++

// Three-way merge operates on individual values; unresolved values retain their
// respective local/remote versions and their old common ancestor.
namespace
{
Json normalizeProfile(Json j)
{
if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array();
for (auto &sn : j.at("snippets"))
if (!sn.contains("id")) sn["id"] = utf8(snippetId({wide(sn.at("trigger").get<std::string>()), L""}));
return j;
}
Json treeProfile(const Json &source)
{
auto j = normalizeProfile(source);
Json items = Json::object();
for (const auto &sn : j.at("snippets"))
{
auto id = sn.at("id").get<std::string>();
if (items.contains(id)) throw std::runtime_error("Duplicate snippet ID.");
items[id] = sn;
}
for (const auto &id : j.at("snippetTombstones"))
{
auto key = id.get<std::string>();
if (items.contains(key)) throw std::runtime_error("Duplicate or deleted snippet ID.");
items[key] = {{"$deleted", true}};
}
j["snippets"] = items;
j.erase("snippetTombstones");
items = Json::object();
for (const auto &binding : j.at("layoutHotkeys")) items[binding.at("layoutId").get<std::string>()] = binding.at("hotkey");
j["layoutHotkeys"] = items;
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
{
items = Json::object();
for (const auto &item : j.at(key)) items[item.get<std::string>()] = true;
j[key] = items;
}
for (auto &language : j.at("autoCorrectWords").items())
{
items = Json::object();
for (const auto &word : language.value()) items[word.get<std::string>()] = true;
language.value() = items;
}
return j;
}
Json flatProfile(Json j)
{
Json snippets = Json::array(), deleted = Json::array();
for (const auto &[id, item] : j.at("snippets").items())
{
if (item.is_null()) continue;
if (item.value("$deleted", false)) deleted.push_back(id);
else snippets.push_back(item);
}
j["snippets"] = snippets; j["snippetTombstones"] = deleted;
Json bindings = Json::array();
for (const auto &[id, hotkey] : j.at("layoutHotkeys").items())
if (!hotkey.is_null()) bindings.push_back({{"layoutId", id}, {"hotkey", hotkey}});
j["layoutHotkeys"] = bindings;
for (auto key : {"excludedApps", "autoCorrectNever", "autoCorrectExcludedApps"})
{
Json list = Json::array();
for (const auto &[item, present] : j.at(key).items()) if (present == true) list.push_back(item);
j[key] = list;
}
for (auto &language : j.at("autoCorrectWords").items())
{
Json list = Json::array();
if (language.value().is_object())
for (const auto &[word, present] : language.value().items()) if (present == true) list.push_back(word);
language.value() = list;
}
return j;
}
std::string pointerKey(const std::string &s)
{
std::string out;
for (char c : s) out += c == '~' ? "~0" : c == '/' ? "~1" : std::string(1, c);
return out;
}
Json canonicalProfile(Json j) { return flatProfile(treeProfile(j)); }
std::wstring fieldLabel(const std::string &key)
{
static const std::map<std::string, std::wstring> labels{
{"toggleHotkey", L"Next layout shortcut"}, {"caseHotkey", L"Change case shortcut"},
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
{"autoCorrectMinLength", L"Minimum word length"}, {"autoCorrectMaxRank", L"Maximum word rank"},
{"autoCorrectLayout", L"Automatic layout correction"}, {"autoCorrectLanguages", L"Dictionary languages"},
{"autoCorrectPrefixChars", L"Punctuation before words"}, {"autoCorrectSuffixChars", L"Punctuation after words"},
{"screenshotAutoOpenEditor", L"Open screenshot editor"}, {"tesseractLanguages", L"OCR languages"},
{"trigger", L"Trigger"}, {"text", L"Text"}};
auto it = labels.find(key); return it == labels.end() ? wide(key) : it->second;
}
struct MergeNode { Json local, remote, base; };
bool tombstone(const Json &j) { return j.is_object() && j.value("$deleted", false); }
MergeNode mergeNode(const Json &b, const Json &l, const Json &r, const std::string &path,
const std::wstring &label, const std::vector<SyncChoice> &choices, std::vector<SyncConflict> &conflicts,
const std::set<std::string> &forced)
{
if (l == r) return {l, l, l};
for (const auto &choice : choices)
if (choice.conflict.key == path && parse(choice.conflict.local) == l && parse(choice.conflict.remote) == r)
{
auto selected = choice.useLocal ? l : r;
if (selected.is_null() && path.starts_with("/snippets/") && path.find('/', 10) == std::string::npos)
selected = {{"$deleted", true}};
return {selected, selected, selected};
}
if (forced.contains(path))
{
conflicts.push_back({path, label + L" (duplicate trigger or shortcut)", bytes(l), bytes(r)});
return {l, r, b};
}
bool forcedChild = std::any_of(forced.begin(), forced.end(), [&](const std::string &key) { return key.starts_with(path + "/"); });
if (!forcedChild && l == b) return {r, r, r};
if (!forcedChild && r == b) return {l, l, l};
if (l.is_object() && r.is_object() && (b.is_object() || b.is_null()) && !tombstone(l) && !tombstone(r) && !tombstone(b))
{
MergeNode out{Json::object(), Json::object(), Json::object()};
std::set<std::string> keys;
for (const auto *j : {&b, &l, &r}) if (j->is_object()) for (auto it = j->begin(); it != j->end(); ++it) keys.insert(it.key());
for (const auto &key : keys)
{
auto get = [&](const Json &j) { return j.is_object() && j.contains(key) ? j.at(key) : Json(); };
auto childLabel = label.empty() ? fieldLabel(key) : label + L" / " + fieldLabel(key);
if (path == "/snippets")
{
auto item = get(l); if (item.is_null() || tombstone(item)) item = get(r);
childLabel = L"Snippet: " + wide(item.is_object() ? item.value("trigger", key) : key);
}
auto child = mergeNode(get(b), get(l), get(r), path + "/" + pointerKey(key), childLabel, choices, conflicts, forced);
// Keep nulls in the ancestor so deletion conflicts survive restarts.
out.local[key] = child.local; out.remote[key] = child.remote; out.base[key] = child.base;
}
return out;
}
conflicts.push_back({path, label, bytes(l), bytes(r)});
return {l, r, b};
}
void collisionPaths(const Json &j, std::set<std::string> &paths)
{
std::map<std::wstring, std::string> triggers;
for (const auto &[id, sn] : j.at("snippets").items())
{
if (sn.is_null() || tombstone(sn)) continue;
auto path = "/snippets/" + pointerKey(id);
auto key = lower(wide(sn.at("trigger").get<std::string>()));
auto [it, inserted] = triggers.emplace(key, path);
if (!inserted) { paths.insert(path); paths.insert(it->second); }
}
std::map<std::pair<UINT, UINT>, std::string> shortcuts;
auto add = [&](const Json &text, const std::string &path) {
if (!text.is_string()) return;
auto hotkey = Hotkey::parse(wide(text.get<std::string>()));
if (!hotkey || !hotkey->key) return;
auto [it, inserted] = shortcuts.emplace(std::make_pair(hotkey->modifiers, hotkey->key), path);
if (!inserted) { paths.insert(path); paths.insert(it->second); }
};
for (const auto *key : {"toggleHotkey", "caseHotkey", "translitHotkey", "undoHotkey", "clipboardHotkey", "screenshotHotkey"}) add(j.at(key), std::string("/") + key);
for (const auto &[id, key] : j.at("layoutHotkeys").items()) add(key, "/layoutHotkeys/" + pointerKey(id));
}
}
SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &remote, const std::vector<SyncChoice> &choices)
{
auto l = treeProfile(parse(local)), r = treeProfile(parse(remote));
if (base.empty() && l != r) throw std::runtime_error("First connection: choose Download remote or Upload local.");
auto b = base.empty() ? l : treeProfile(parse(base));
SyncMerge result;
auto merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, {});
std::set<std::string> collisions;
collisionPaths(merged.local, collisions); collisionPaths(merged.remote, collisions);
if (!collisions.empty())
{
result.conflicts.clear();
merged = mergeNode(b, l, r, "", L"", choices, result.conflicts, collisions);
}
result.local = bytes(flatProfile(merged.local));
result.remote = bytes(flatProfile(merged.remote));
result.base = bytes(flatProfile(merged.base));
return result;
}
Bytes mergeSyncSettings(const Bytes &base, const Bytes &local, const Bytes &remote)
{
auto result = mergeSyncProfile(base, local, remote);
if (!result.conflicts.empty()) throw std::runtime_error("Conflicting setting: " + result.conflicts.front().key);
return result.local;
}