From 0c206226b1b946af315738097b618fd9a57de33e Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 21 Dec 2022 14:51:39 +0900 Subject: [PATCH] New feature - JSON merging for data and canvas. --- src/main.ts | 88 +++++++++++++++++++++++++++++++---- src/utils.ts | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++- styles.css | 7 ++- 3 files changed, 212 insertions(+), 10 deletions(-) diff --git a/src/main.ts b/src/main.ts index 1f48aab..209dbed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,7 +29,7 @@ import { DocumentHistoryModal } from "./DocumentHistoryModal"; -import { clearAllPeriodic, clearAllTriggers, clearTrigger, disposeMemoObject, id2path, memoIfNotExist, memoObject, path2id, retrieveMemoObject, setTrigger } from "./utils"; +import { applyPatch, clearAllPeriodic, clearAllTriggers, clearTrigger, disposeMemoObject, generatePatchObj, id2path, isObjectMargeApplicable, isSensibleMargeApplicable, memoIfNotExist, memoObject, path2id, retrieveMemoObject, setTrigger, tryParseJSON } from "./utils"; import { decrypt, encrypt } from "./lib/src/e2ee_v2"; const isDebug = false; @@ -2129,6 +2129,36 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } } + async mergeObject(path: string, baseRev: string, currentRev: string, conflictedRev: string): Promise { + const baseLeaf = await this.getConflictedDoc(path, baseRev); + const leftLeaf = await this.getConflictedDoc(path, currentRev); + const rightLeaf = await this.getConflictedDoc(path, conflictedRev); + if (baseLeaf == false || leftLeaf == false || rightLeaf == false) { + return false; + } + const baseObj = { data: tryParseJSON(baseLeaf.data, {}) } as Record; + const leftObj = { data: tryParseJSON(leftLeaf.data, {}) } as Record; + const rightObj = { data: tryParseJSON(rightLeaf.data, {}) } as Record; + + const diffLeft = generatePatchObj(baseObj, leftObj); + const diffRight = generatePatchObj(baseObj, rightObj); + const patches = [ + { mtime: leftLeaf.mtime, patch: diffLeft }, + { mtime: rightLeaf.mtime, patch: diffRight } + ].sort((a, b) => a.mtime - b.mtime); + let newObj = { ...baseObj }; + try { + for (const patch of patches) { + newObj = applyPatch(newObj, patch.patch); + } + return JSON.stringify(newObj.data); + } catch (ex) { + Logger("Could not merge object"); + Logger(ex, LOG_LEVEL.VERBOSE) + return false; + } + } + /** * Getting file conflicted status. * @param path the file location @@ -2141,21 +2171,38 @@ export default class ObsidianLiveSyncPlugin extends Plugin { if (!test._conflicts) return false; if (test._conflicts.length == 0) return false; const conflicts = test._conflicts.sort((a, b) => Number(a.split("-")[0]) - Number(b.split("-")[0])); - - if (path.endsWith(".md") && !this.settings.disableMarkdownAutoMerge) { + if ((isSensibleMargeApplicable(path) || isObjectMargeApplicable(path)) && !this.settings.disableMarkdownAutoMerge) { const conflictedRev = conflicts[0]; const conflictedRevNo = Number(conflictedRev.split("-")[0]); //Search const revFrom = (await this.localDatabase.localDatabase.get(id2path(path), { revs_info: true })) as unknown as LoadedEntry & PouchDB.Core.GetMeta; const commonBase = revFrom._revs_info.filter(e => e.status == "available" && Number(e.rev.split("-")[0]) < conflictedRevNo).first().rev ?? ""; + let p = undefined; if (commonBase) { - const result = await this.mergeSensibly(path, commonBase, test._rev, conflictedRev); - if (result) { + if (isSensibleMargeApplicable(path)) { + const result = await this.mergeSensibly(path, commonBase, test._rev, conflictedRev); + if (result) { + p = result.filter(e => e[0] != DIFF_DELETE).map((e) => e[1]).join(""); + // can be merged. + Logger(`Sensible merge:${path}`, LOG_LEVEL.INFO); + } else { + Logger(`Sensible merge is not applicable.`, LOG_LEVEL.VERBOSE); + } + } else if (isObjectMargeApplicable(path)) { // can be merged. - Logger(`Sensible merge:${path}`, LOG_LEVEL.INFO); + const result = await this.mergeObject(path, commonBase, test._rev, conflictedRev); + if (result) { + Logger(`Object merge:${path}`, LOG_LEVEL.INFO); + p = result; + } else { + Logger(`Object merge is not applicable.`, LOG_LEVEL.VERBOSE); + } + } + + if (p != undefined) { // remove conflicted revision. await this.localDatabase.deleteDBEntry(path, { rev: conflictedRev }); - const p = result.filter(e => e[0] != DIFF_DELETE).map((e) => e[1]).join(""); + const file = getAbstractFileByPath(path) as TFile; if (file) { await this.app.vault.modify(file, p); @@ -2914,8 +2961,33 @@ export default class ObsidianLiveSyncPlugin extends Plugin { if (!("_conflicts" in doc)) return false; if (doc._conflicts.length == 0) return false; Logger(`Hidden file conflicted:${id2filenameInternalChunk(id)}`); + const conflicts = doc._conflicts.sort((a, b) => Number(a.split("-")[0]) - Number(b.split("-")[0])); + const revA = doc._rev; - const revB = doc._conflicts[0]; + const revB = conflicts[0]; + + const conflictedRev = conflicts[0]; + const conflictedRevNo = Number(conflictedRev.split("-")[0]); + //Search + const revFrom = (await this.localDatabase.localDatabase.get(id, { revs_info: true })) as unknown as LoadedEntry & PouchDB.Core.GetMeta; + const commonBase = revFrom._revs_info.filter(e => e.status == "available" && Number(e.rev.split("-")[0]) < conflictedRevNo).first().rev ?? ""; + const result = await this.mergeObject(id, commonBase, doc._rev, conflictedRev); + if (result) { + Logger(`Object merge:${id}`, LOG_LEVEL.INFO); + const filename = id2filenameInternalChunk(id); + const isExists = await this.app.vault.adapter.exists(filename); + if (!isExists) { + await this.ensureDirectoryEx(filename); + } + await this.app.vault.adapter.write(filename, result); + const stat = await this.app.vault.adapter.stat(filename); + await this.storeInternalFileToDatabase({ path: filename, ...stat }); + await this.extractInternalFileFromDatabase(filename); + await this.localDatabase.localDatabase.remove(id, revB); + return this.resolveConflictOnInternalFile(id); + } else { + Logger(`Object merge is not applicable.`, LOG_LEVEL.VERBOSE); + } const revBDoc = await this.localDatabase.localDatabase.get(id, { rev: revB }); // determine which revision should been deleted. diff --git a/src/utils.ts b/src/utils.ts index e614976..a515f7b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -72,4 +72,129 @@ export function retrieveMemoObject(key: string): T | false { } export function disposeMemoObject(key: string) { delete memos[key]; -} \ No newline at end of file +} + +export function isSensibleMargeApplicable(path: string) { + if (path.endsWith(".md")) return true; + return false; +} +export function isObjectMargeApplicable(path: string) { + if (path.endsWith(".canvas")) return true; + if (path.endsWith(".json")) return true; + return false; +} + +export function tryParseJSON(str: string, fallbackValue?: any) { + try { + return JSON.parse(str); + } catch (ex) { + return fallbackValue; + } +} + +const MARK_OPERATOR = `\u{0001}`; +const MARK_DELETED = `${MARK_OPERATOR}__DELETED`; +const MARK_ISARRAY = `${MARK_OPERATOR}__ARRAY`; +const MARK_SWAPPED = `${MARK_OPERATOR}__SWAP`; + +function unorderedArrayToObject(obj: Array) { + return obj.map(e => ({ [e.id as string]: e })).reduce((p, c) => ({ ...p, ...c }), {}) +} +function objectToUnorderedArray(obj: object) { + const entries = Object.entries(obj); + if (entries.some(e => e[0] != e[1]?.id)) throw new Error("Item looks like not unordered array") + return entries.map(e => e[1]); +} +function generatePatchUnorderedArray(from: Array, to: Array) { + if (from.every(e => typeof (e) == "object" && ("id" in e)) && to.every(e => typeof (e) == "object" && ("id" in e))) { + const fObj = unorderedArrayToObject(from); + const tObj = unorderedArrayToObject(to); + const diff = generatePatchObj(fObj, tObj); + if (Object.keys(diff).length > 0) { + return { [MARK_ISARRAY]: diff }; + } else { + return {}; + } + } + return { [MARK_SWAPPED]: to }; +} + +export function generatePatchObj(from: Record, to: Record) { + const entries = Object.entries(from); + const tempMap = new Map(entries); + const ret = {} as Record; + const newEntries = Object.entries(to); + for (const [key, value] of newEntries) { + if (!tempMap.has(key)) { + //New + ret[key] = value; + tempMap.delete(key); + } else { + //Exists + const v = tempMap.get(key); + if (typeof (v) !== typeof (value) || (Array.isArray(v) !== Array.isArray(value))) { + //if type is not match, replace completely. + ret[key] = { [MARK_SWAPPED]: value }; + } else { + if (typeof (v) == "object" && typeof (value) == "object" && !Array.isArray(v) && !Array.isArray(value)) { + const wk = generatePatchObj(v, value); + if (Object.keys(wk).length > 0) ret[key] = wk; + } else if (typeof (v) == "object" && typeof (value) == "object" && Array.isArray(v) && Array.isArray(value)) { + const wk = generatePatchUnorderedArray(v, value); + if (Object.keys(wk).length > 0) ret[key] = wk; + } else if (typeof (v) != "object" && typeof (value) != "object") { + if (JSON.stringify(tempMap.get(key)) !== JSON.stringify(value)) { + ret[key] = value; + } + } else { + if (JSON.stringify(tempMap.get(key)) !== JSON.stringify(value)) { + ret[key] = { [MARK_SWAPPED]: value }; + } + } + } + tempMap.delete(key); + } + } + //Not used item, means deleted one + for (const [key,] of tempMap) { + ret[key] = MARK_DELETED + } + return ret; +} + + +export function applyPatch(from: Record, patch: Record) { + const ret = from; + const patches = Object.entries(patch); + for (const [key, value] of patches) { + if (value == MARK_DELETED) { + delete ret[key]; + continue; + } + if (typeof (value) == "object") { + if (MARK_SWAPPED in value) { + ret[key] = value[MARK_SWAPPED]; + continue; + } + if (MARK_ISARRAY in value) { + if (!(key in ret)) ret[key] = []; + if (!Array.isArray(ret[key])) { + throw new Error("Patch target type is mismatched (array to something)"); + } + const orgArrayObject = unorderedArrayToObject(ret[key]); + const appliedObject = applyPatch(orgArrayObject, value[MARK_ISARRAY]); + const appliedArray = objectToUnorderedArray(appliedObject); + ret[key] = [...appliedArray]; + } else { + if (!(key in ret)) { + ret[key] = value; + continue; + } + ret[key] = applyPatch(ret[key], value); + } + } else { + ret[key] = value; + } + } + return ret; +} diff --git a/styles.css b/styles.css index a67c8a8..d9b1959 100644 --- a/styles.css +++ b/styles.css @@ -106,7 +106,8 @@ } .CodeMirror-wrap::before, -.cm-s-obsidian>.cm-editor::before { +.cm-s-obsidian>.cm-editor::before, +.canvas-wrapper::before { content: var(--slsmessage); text-align: right; white-space: pre-wrap; @@ -122,6 +123,10 @@ filter: grayscale(100%); } +.canvas-wrapper::before { + right: 48px; +} + .CodeMirror-wrap::before { right: 0px; }