// 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()), 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(); 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(); 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()] = 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()] = true; j[key] = items; } for (auto &language : j.at("autoCorrectWords").items()) { items = Json::object(); for (const auto &word : language.value()) items[word.get()] = 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 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 &choices, std::vector &conflicts, const std::set &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 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 &paths) { std::map 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())); auto [it, inserted] = triggers.emplace(key, path); if (!inserted) { paths.insert(path); paths.insert(it->second); } } std::map, std::string> shortcuts; auto add = [&](const Json &text, const std::string &path) { if (!text.is_string()) return; auto hotkey = Hotkey::parse(wide(text.get())); 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 &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 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; }