diff --git a/docs/tips/hidden-file-sync.md b/docs/tips/hidden-file-sync.md index 4bf0ca85..e5b74c55 100644 --- a/docs/tips/hidden-file-sync.md +++ b/docs/tips/hidden-file-sync.md @@ -72,6 +72,12 @@ Hidden File Sync must be enabled independently after ordinary setup on each devi If you later run Rebuild or Fetch as a recovery operation, expect optional features to be disabled again. Complete ordinary recovery first, then repeat this guide deliberately. +## Resolve a JSON conflict + +When automatic merging cannot combine conflicting JSON changes and no overwrite pattern applies, the **Conflicted Setting** dialogue compares two revisions. Review the proposed contents before choosing **Apply**. Closing the dialogue leaves the conflict unresolved. + +A later conflict check for the same file cancels the earlier comparison and checks the latest revisions again. If manual resolution is still needed, a replacement dialogue opens. A cancelled dialogue cannot apply a later selection. A resolution received through replication closes the comparison for that file; a remaining conflict is checked again. Dialogues for other files wait their turn. An **Apply** operation which has already started finishes before the replacement proceeds. + ## Automatic in Customisation Sync **Automatic** delegates an item's files to Hidden File Sync. Enable Hidden File Sync on this device through the steps above before choosing Automatic. If Hidden File Sync is disabled, the choice displays a Notice without changing the item's mode or starting initialisation. Previously saved Automatic assignments remain saved, so this prerequisite also applies after importing settings from another device. diff --git a/src/features/HiddenFileCommon/JsonResolveModal.ts b/src/features/HiddenFileCommon/JsonResolveModal.ts index 967645e2..6026702a 100644 --- a/src/features/HiddenFileCommon/JsonResolveModal.ts +++ b/src/features/HiddenFileCommon/JsonResolveModal.ts @@ -43,11 +43,12 @@ export class JsonResolveModal extends Modal { } async UICallback(keepRev?: string, mergedStr?: string) { - if (this.callback) { - await this.callback(keepRev, mergedStr); + const callback = this.callback; + this.callback = undefined; + if (callback) { + await callback(keepRev, mergedStr); } this.close(); - this.callback = undefined; } override onOpen() { @@ -78,8 +79,10 @@ export class JsonResolveModal extends Modal { const { contentEl } = this; contentEl.empty(); // contentEl.empty(); - if (this.callback != undefined) { - void this.callback(undefined); + const callback = this.callback; + this.callback = undefined; + if (callback) { + void callback(undefined); } if (this.component != undefined) { void unmount(this.component); diff --git a/src/features/HiddenFileCommon/JsonResolveModal.unit.spec.ts b/src/features/HiddenFileCommon/JsonResolveModal.unit.spec.ts new file mode 100644 index 00000000..c75f6451 --- /dev/null +++ b/src/features/HiddenFileCommon/JsonResolveModal.unit.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { sendSignal } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { JsonResolveModal } from "./JsonResolveModal.ts"; + +vi.mock("@/deps.ts", () => ({ + App: class App {}, + Modal: class Modal { + contentEl = { empty: vi.fn() }; + close() { + (this as { onClose?: () => void }).onClose?.(); + } + }, +})); +vi.mock("./JsonResolvePane.svelte", () => ({ default: {} })); +vi.mock("svelte", () => ({ mount: vi.fn(), unmount: vi.fn() })); + +describe("JsonResolveModal completion", () => { + it("applies a selection once when closing the dialogue", async () => { + const filename = ".obsidian/selected-once.json" as FilePath; + const callback = vi.fn(async () => {}); + const modal = new JsonResolveModal({} as never, filename, [], callback); + + await modal.UICallback("2-kept"); + + expect(callback).toHaveBeenCalledExactlyOnceWith("2-kept", undefined); + sendSignal(`cancel-internal-conflict:${filename}`); + }); + + it("ignores a late selection after cancellation", async () => { + const filename = ".obsidian/cancelled-once.json" as FilePath; + const callback = vi.fn(async () => {}); + const modal = new JsonResolveModal({} as never, filename, [], callback); + + sendSignal(`cancel-internal-conflict:${filename}`); + await vi.waitFor(() => expect(callback).toHaveBeenCalledOnce()); + await modal.UICallback("2-stale"); + + expect(callback).toHaveBeenCalledExactlyOnceWith(undefined); + }); + + it("keeps an admitted write pending when cancellation closes its dialogue", async () => { + const filename = ".obsidian/writing-once.json" as FilePath; + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const callback = vi.fn(async () => await gate); + const modal = new JsonResolveModal({} as never, filename, [], callback); + const close = vi.spyOn(modal, "close"); + let completed = false; + const selected = modal.UICallback("2-kept").then(() => { + completed = true; + }); + + try { + sendSignal(`cancel-internal-conflict:${filename}`); + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + + expect(callback).toHaveBeenCalledExactlyOnceWith("2-kept", undefined); + expect(completed).toBe(false); + } finally { + finish(); + await selected; + } + }); +}); diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.queue.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.queue.unit.spec.ts index 056e46a7..a7613ee8 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.queue.unit.spec.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.queue.unit.spec.ts @@ -18,6 +18,7 @@ import { type MetaEntry, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { HiddenFileSync } from "./CmdHiddenFileSync"; +import { sendSignal, waitForSignal } from "@vrtmrz/livesync-commonlib/compat/common/utils"; const queueOwners: HiddenFileSync[] = []; @@ -133,6 +134,132 @@ describe("HiddenFileSync conflict queue", () => { } }); + it.each(["prefixed", "unprefixed"])( + "cancels the older same-file dialogue and presents the latest revisions for a %s request", + async (form) => { + vi.useFakeTimers(); + const filename = `.obsidian/replacement-${form}.json`; + const path = `i:${filename}` as FilePathWithPrefix; + const id = String(path) as DocumentID; + let generation = 2; + const localDatabase = { + getRaw: vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) => + options?.revs_info + ? {} + : { _id: id, path, _rev: `${generation}-current`, _conflicts: [`${generation}-other`] } + ), + getDBEntry: vi.fn( + async (_path: FilePathWithPrefix, options: { rev: string }) => + ({ _id: id, path, _rev: options.rev }) as LoadedEntry + ), + managers: { conflictManager: { mergeObject: vi.fn(async () => false) } }, + }; + const { hiddenFileSync } = createHiddenFileSync(localDatabase); + hiddenFileSync.path2id = async () => id; + const finishFirst = deferred(); + let cancelled = false; + const opened: string[][] = []; + hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn(async (left, right) => { + opened.push([left._rev!, right._rev!]); + if (opened.length > 1) return false; + return await Promise.race([ + finishFirst.promise, + waitForSignal(`cancel-internal-conflict:${filename}`).then(() => { + cancelled = true; + return false; + }), + ]); + }); + + try { + hiddenFileSync.queueConflictCheck(path); + await vi.advanceTimersByTimeAsync(100); + expect(opened).toEqual([["2-current", "2-other"]]); + + generation = 3; + hiddenFileSync.queueConflictCheck((form === "prefixed" ? path : filename) as FilePathWithPrefix); + await vi.advanceTimersByTimeAsync(100); + + expect(cancelled).toBe(true); + expect(opened).toEqual([ + ["2-current", "2-other"], + ["3-current", "3-other"], + ]); + expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true); + expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); + } finally { + finishFirst.resolve(false); + sendSignal(`cancel-internal-conflict:${filename}`); + await vi.advanceTimersByTimeAsync(100); + } + } + ); + + it.each(["same", "different"])( + "closes only a dialogue for the %s file when a replicated resolution arrives", + async (target) => { + vi.useFakeTimers(); + const filename = `.obsidian/received-${target}.json`; + const path = `i:${filename}` as FilePathWithPrefix; + const incomingPath = (target === "same" ? path : "i:.obsidian/other-resolved.json") as FilePathWithPrefix; + const id = String(path) as DocumentID; + let resolved = false; + const localDatabase = { + getRaw: vi.fn(async (_id: DocumentID, options?: { revs_info?: boolean }) => + options?.revs_info + ? {} + : { + _id: id, + path, + _rev: resolved ? "3-resolved" : "2-current", + _conflicts: resolved ? [] : ["2-other"], + } + ), + getDBEntry: vi.fn(async () => ({ _id: id, path }) as LoadedEntry), + managers: { conflictManager: { mergeObject: vi.fn(async () => false) } }, + }; + const { hiddenFileSync } = createHiddenFileSync(localDatabase); + hiddenFileSync.path2id = async () => id; + hiddenFileSync.getPath = () => incomingPath; + hiddenFileSync.isTargetFile = vi.fn(async () => true); + hiddenFileSync.trackDatabaseFileModification = vi.fn(async () => true); + const finishFirst = deferred(); + let cancelled = false; + hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn( + async () => + await Promise.race([ + finishFirst.promise, + waitForSignal(`cancel-internal-conflict:${filename}`).then(() => { + cancelled = true; + return false; + }), + ]) + ); + + try { + hiddenFileSync.queueConflictCheck(path); + await vi.advanceTimersByTimeAsync(100); + expect(hiddenFileSync.showJSONMergeDialogAndMerge).toHaveBeenCalledOnce(); + + resolved = target === "same"; + const incoming = { _id: incomingPath, path: incomingPath, _rev: "3-received" } as LoadedEntry; + expect(await hiddenFileSync._anyProcessOptionalSyncFiles(incoming)).toBe(true); + await vi.advanceTimersByTimeAsync(100); + + expect(cancelled).toBe(target === "same"); + expect(hiddenFileSync.showJSONMergeDialogAndMerge).toHaveBeenCalledOnce(); + expect(hiddenFileSync.pendingConflictChecks.size).toBe(target === "same" ? 0 : 1); + if (target === "same") { + expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true); + } + } finally { + finishFirst.resolve(false); + sendSignal(`cancel-internal-conflict:${filename}`); + await vi.advanceTimersByTimeAsync(100); + } + } + ); + it("waits for a cancelled manual merge dialogue and then finishes without requeueing", async () => { vi.useFakeTimers(); const path = "i:.obsidian/plugins/example/data.json" as FilePathWithPrefix; @@ -225,7 +352,7 @@ describe("HiddenFileSync pipeline contracts", () => { gates.forEach((gate, index) => gate.resolve({ path: paths[index] } as MetaEntry)); await vi.advanceTimersByTimeAsync(100); expect(await hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed()).toBe(true); - expect(reads).toEqual(paths); + expect(reads).toEqual([...paths, paths[0]]); expect(hiddenFileSync.pendingConflictChecks.size).toBe(0); }); @@ -233,6 +360,8 @@ describe("HiddenFileSync pipeline contracts", () => { vi.useFakeTimers(); const paths = Array.from({ length: 12 }, (_, index) => `i:.obsidian/${index}.json` as FilePathWithPrefix); const gates = paths.map(() => deferred()); + const firstDialogue = deferred(); + let firstCancelled = false; const localDatabase = { getRaw: vi.fn(async (id: DocumentID, options?: { revs_info?: boolean }) => options?.revs_info @@ -252,6 +381,15 @@ describe("HiddenFileSync pipeline contracts", () => { const opened: FilePathWithPrefix[] = []; hiddenFileSync.showJSONMergeDialogAndMerge = vi.fn((doc) => { opened.push(doc.path); + if (opened.length === 1) { + return Promise.race([ + firstDialogue.promise, + waitForSignal(`cancel-internal-conflict:${doc.path.slice(2)}`).then(() => { + firstCancelled = true; + return false; + }), + ]); + } return gates[paths.indexOf(doc.path)].promise; }); paths.forEach((path) => hiddenFileSync.queueConflictCheck(path)); @@ -260,12 +398,16 @@ describe("HiddenFileSync pipeline contracts", () => { expect(opened).toEqual([paths[0]]); paths.forEach((path) => hiddenFileSync.queueConflictCheck(path)); expect(hiddenFileSync.pendingConflictChecks.size).toBe(12); + await vi.advanceTimersByTimeAsync(100); + expect(firstCancelled).toBe(true); + expect(opened).toEqual([paths[0], paths[0]]); + expect(localDatabase.managers.conflictManager.mergeObject).toHaveBeenCalledTimes(24); let completed = false; void hiddenFileSync.conflictResolutionProcessor.waitForAllProcessed().then(() => { completed = true; }); for (let index = 0; index < paths.length; index++) { - expect(opened).toEqual(paths.slice(0, index + 1)); + expect(opened).toEqual([paths[0], ...paths.slice(0, index + 1)]); expect(completed).toBe(false); gates[index].resolve(false); await vi.advanceTimersByTimeAsync(100); diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.ts index 20e70d11..e9c32a96 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.ts @@ -741,21 +741,33 @@ Offline Changed files: ${processFiles.length}`; // --> Conflict processing - // Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue. - pendingConflictChecks = new Set(); + // Coalesce unstarted checks; retain a replacement once processing has begun. + pendingConflictChecks = new Map(); queueConflictCheck(path: FilePathWithPrefix) { - if (this.pendingConflictChecks.has(path)) return; - this.pendingConflictChecks.add(path); + path = addPrefix(path, ICHeader); + const pending = this.pendingConflictChecks.get(path); + if (pending) { + if (pending.started) { + pending.recheck = true; + sendSignal(`cancel-internal-conflict:${stripAllPrefixes(path)}`); + } + return; + } + this.pendingConflictChecks.set(path, { started: false, recheck: false }); if (!this.conflictResolutionProcessor.enqueue(path)) this.pendingConflictChecks.delete(path); } finishConflictCheck(path: FilePathWithPrefix) { + path = addPrefix(path, ICHeader); + const pending = this.pendingConflictChecks.get(path); this.pendingConflictChecks.delete(path); + if (pending?.recheck) this.queueConflictCheck(path); } requeueConflictCheck(path: FilePathWithPrefix) { - this.finishConflictCheck(path); + path = addPrefix(path, ICHeader); + this.pendingConflictChecks.delete(path); this.queueConflictCheck(path); } @@ -806,10 +818,16 @@ Offline Changed files: ${processFiles.length}`; } conflictResolutionProcessor = new HiddenFileConflictQueue( async (path: FilePathWithPrefix) => { + const check = this.pendingConflictChecks.get(path); + if (check) check.started = true; try { // Retrieve data const id = await this.path2id(path, ICHeader); const doc = await this.localDatabase.getRaw(id, { conflicts: true }); + if (check?.recheck) { + this.requeueConflictCheck(path); + return []; + } if (doc._conflicts === undefined) { this.finishConflictCheck(path); return []; @@ -838,6 +856,10 @@ Offline Changed files: ${processFiles.length}`; doc._rev, conflictedRev ); + if (check?.recheck) { + this.requeueConflictCheck(path); + return []; + } if (result) { this._log(`Object merge:${path}`, LOG_LEVEL_INFO); const filename = stripAllPrefixes(path); @@ -861,7 +883,7 @@ Offline Changed files: ${processFiles.length}`; await this.resolveByNewerEntry(id, path, doc, revA, revB); return []; } - return [{ path, revA, revB, id, doc }]; + return [{ path, revA, revB, id, doc, check }]; } // When not JSON file, resolve conflicts by choosing a newer one. await this.resolveByNewerEntry(id, path, doc, revA, revB); @@ -874,10 +896,18 @@ Offline Changed files: ${processFiles.length}`; } }, async (result) => { - const { id, doc, path, revA, revB } = result; + const { id, doc, path, revA, revB, check } = result; + if (check?.recheck) { + this.requeueConflictCheck(path); + return; + } const prefixedPath = addPrefix(path, ICHeader); const docAMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revA }); const docBMerge = await this.localDatabase.getDBEntry(prefixedPath, { rev: revB }); + if (check?.recheck) { + this.requeueConflictCheck(path); + return; + } try { if (docAMerge != false && docBMerge != false) { if (await this.showJSONMergeDialogAndMerge(docAMerge, docBMerge)) { @@ -912,7 +942,7 @@ Offline Changed files: ${processFiles.length}`; const displayFilename = `${storeFilePath}`; // const path = this.prefixedConfigDir2configDir(stripAllPrefixes(docA.path)) || docA.path; // Cancel only when replacing an existing dialogue for the same path, not on every queue pass. - sendSignal(`cancel-internal-conflict:${docA.path}`); + sendSignal(`cancel-internal-conflict:${storageFilePath}`); const modal = new JsonResolveModal(this.app, storageFilePath, [docA, docB], async (keep, result) => { // modal.close(); try { @@ -998,6 +1028,9 @@ Offline Changed files: ${processFiles.length}`; async processReplicationResult(doc: LoadedEntry): Promise { const info = this.getDocProps(doc); const path = info.path; + if (this.pendingConflictChecks.has(addPrefix(path, ICHeader))) { + this.queueConflictCheck(info.prefixedPath); + } const headerLine = `Tracking DB ${info.path} (${info.revDisplay}) :`; const ret = await this.trackDatabaseFileModification(path, headerLine); this._log(`${headerLine} Done: ${info.shortenedId})`, LOG_LEVEL_VERBOSE); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 0f16f86b..5124b640 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -272,6 +272,8 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms The JSON conflict cases enter through `resolveConflictOnInternalFiles()`, exercising the analysis queue and the serial manual-resolution queue. They check the stored content and remaining conflict revisions as well as the Vault files; the manual case must remain unfinished until a choice is made. +The conflict cases also verify same-file dialogue replacement and received resolutions. A newer check closes the older JSON comparison, reads the latest revisions, and preserves both leaves until selection. A resolution delivered to the registered optional-file replication handler closes only the affected dialogue, drains its queue, and retains the winner and Vault bytes; an unrelated file is a control. This isolates the Obsidian receive boundary from peer and transport setup. Use `--case=replacement` or `--case=external-resolution` to run one of these cases alone, with the CouchDB fixture already running. + Separate Vault pairs begin with Hidden File Sync disabled and enable it through the visible `Merge`, `Fetch`, and `Overwrite` buttons. Each pair contains a newer local file, a newer database file, and files present on only one side. The workflow verifies the selected contents in the files and database, the saved enablement setting, persistence after restart, and convergence on the other device. `--case=conflicts` runs only the conflict cases; `--case=enablement` runs only these enablement cases. Add `--mode=Merge`, `--mode=Fetch`, or `--mode=Overwrite` to select one enablement method. `test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database, and also runs in the complete local suite. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path. diff --git a/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts index 0853684f..4105aeca 100644 --- a/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts +++ b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts @@ -62,7 +62,12 @@ const snippetContent = [ const mergeJsonPath = ".obsidian/livesync-e2e-merge.json"; const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json"; +const replacementMergeJsonPath = ".obsidian/livesync-e2e-manual-replacement.json"; +const externalResolutionTargetPath = ".obsidian/livesync-e2e-external-resolution.json"; +const externalResolutionUnrelatedPath = ".obsidian/livesync-e2e-external-unrelated.json"; const hiddenConflictStateKey = "__livesyncE2EHiddenConflictResolution"; +const hiddenConflictComparisonStateKey = "__livesyncE2EHiddenConflictComparison"; +const hiddenConflictModalStateKey = "__livesyncE2EHiddenConflictModal"; const targetPath = ".obsidian/livesync-targeted/only-a.json"; const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000); const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation"; @@ -326,6 +331,8 @@ async function startHiddenConflictResolution(cliBinary: string, env: NodeJS.Proc `const key=${JSON.stringify(hiddenConflictStateKey)};`, "const state={done:false,error:null};", "globalThis[key]=state;", + `const comparison=globalThis[${JSON.stringify(hiddenConflictComparisonStateKey)}];`, + "if(comparison)comparison.operation=state;", "const addOn=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');", "void addOn.resolveConflictOnInternalFiles().then(", "()=>{state.done=true;},", @@ -338,6 +345,61 @@ async function startHiddenConflictResolution(cliBinary: string, env: NodeJS.Proc ); } +type HiddenJsonComparisonCall = { + leftRev: string | null; + rightRev: string | null; + done: boolean; + result: unknown; + error: string | null; +}; + +type HiddenJsonComparisonObservation = { + pipelineDone: boolean; + calls: HiddenJsonComparisonCall[]; +}; + +async function installHiddenJsonComparisonObserver(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(()=>{", + `const key=${JSON.stringify(hiddenConflictComparisonStateKey)};`, + "const addOn=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');", + "const original=addOn.showJSONMergeDialogAndMerge;", + "if(typeof original!=='function')throw new Error('Hidden File Sync JSON comparison method is unavailable');", + "const state={calls:[],original,operation:null};", + "state.wrapper=function(left,right){", + "const call={leftRev:left?._rev??null,rightRev:right?._rev??null,done:false,result:null,error:null};", + "state.calls.push(call);", + "const result=state.original.call(this,left,right);", + "Promise.resolve(result).then(value=>{call.done=true;call.result=typeof value==='boolean'||value===null?value:String(value);},error=>{call.done=true;call.error=String(error);});", + "return result;};", + "addOn.showJSONMergeDialogAndMerge=state.wrapper;", + "globalThis[key]=state;return JSON.stringify({installed:true});", + "})()", + ].join(""), + env + ); +} + +async function readHiddenJsonComparisonObservation( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(()=>{", + `const state=globalThis[${JSON.stringify(hiddenConflictComparisonStateKey)}];`, + "const operation=state?.operation;", + "if(!state||!operation)throw new Error('Hidden JSON comparison observation is unavailable');", + "return JSON.stringify({pipelineDone:operation.done,calls:state.calls});", + "})()", + ].join(""), + env + ); +} + async function waitForHiddenConflictResolution(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, @@ -396,6 +458,51 @@ async function assertResolvedHiddenContent( assertEqual(content, expectedContent, `Stored hidden content differs from the Vault file: ${path}.`); } +type HiddenConflictLeaf = { + rev: string; + datatype: string; + data: string | string[]; +}; + +async function readHiddenConflictLeaves( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string +): Promise<{ currentRev: string; conflictRevs: string[]; leaves: HiddenConflictLeaf[] }> { + return await evalObsidianJson<{ + currentRev: string; + conflictRevs: string[]; + leaves: HiddenConflictLeaf[]; + }>( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase;", + "const entry=await db.getDBEntry(`i:${path}`);", + "if(!entry)throw new Error(`Hidden conflict entry is missing: ${path}`);", + "const current=await db.getRaw(entry._id,{conflicts:true});", + "const conflictRevs=current._conflicts||[];", + "const leaves=await Promise.all([current._rev,...conflictRevs].map(async rev=>{", + "const doc=await db.getDBEntry(`i:${path}`,{rev},false,true,true);", + "if(!doc)throw new Error(`Could not load hidden conflict revision ${rev}: ${path}`);", + "return {rev:doc._rev,datatype:doc.datatype,data:doc.data};}));", + "if(leaves.some(leaf=>!leaf.datatype||leaf.data===undefined))throw new Error(`Could not read every hidden conflict leaf: ${path}`);", + "return JSON.stringify({currentRev:current._rev,conflictRevs,leaves});", + "})()", + ].join(""), + env + ); +} + +function decodeHiddenConflictLeaf(leaf: HiddenConflictLeaf): string { + return leaf.datatype === "plain" + ? typeof leaf.data === "string" + ? leaf.data + : leaf.data.join("") + : new TextDecoder().decode(decodeBinary(leaf.data)); +} + async function storeHiddenFileAsConflict( cliBinary: string, env: NodeJS.ProcessEnv, @@ -676,6 +783,493 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te console.log(`Hidden JSON conflict modal applied the selected merged result. Screenshot: ${screenshotPath}`); } +async function runJsonManualConflictReplacement(context: RunnerContext, vault: TemporaryVault): Promise { + const base = JSON.stringify({ shared: "base" }, null, 4) + "\n"; + const left = JSON.stringify({ shared: "left", fromA: true }, null, 4) + "\n"; + const right = JSON.stringify({ shared: "right", fromB: true }, null, 4) + "\n"; + const latestMarker = `hidden-json-replacement-${context.dbName}`; + const latest = JSON.stringify({ shared: "latest", fromA: true, fromB: true, latestMarker }, null, 4) + "\n"; + const session = await startConfiguredSession(context, vault); + + await createHiddenJsonConflict(context, session, vault, replacementMergeJsonPath, base, left, right); + await installHiddenJsonComparisonObserver(context.cliBinary, session.cliEnv); + + await startHiddenConflictResolution(context.cliBinary, session.cliEnv); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const firstOption = page.locator('label:has(input[name="disp"][value="AB"])').last(); + await firstOption.waitFor({ state: "visible", timeout: 10000 }); + await firstOption.evaluate((element, key) => { + const firstModal = element.closest(".modal-container"); + if (!(firstModal instanceof HTMLElement)) + throw new Error("The first JSON conflict dialogue has no modal container"); + (globalThis as unknown as Record)[key] = { firstModal }; + }, hiddenConflictModalStateKey); + }); + + const firstDialogueState = await readHiddenJsonComparisonObservation(context.cliBinary, session.cliEnv); + assertEqual( + firstDialogueState.pipelineDone, + false, + "The hidden conflict pipeline completed before replacing its first dialogue." + ); + assertEqual(firstDialogueState.calls.length, 1, "The first hidden JSON comparison was not observed exactly once."); + assertEqual( + firstDialogueState.calls[0].done, + false, + "The first hidden JSON comparison completed before the replacement request." + ); + + await writeVaultFile(vault.path, replacementMergeJsonPath, latest); + const updatedWinner = await evalObsidianJson<{ rev: string }>( + context.cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(replacementMergeJsonPath)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "const db=core.localDatabase;", + "const entry=await db.getDBEntry(`i:${path}`);", + "if(!entry)throw new Error(`Hidden conflict winner is missing: ${path}`);", + "const current=await db.getRaw(entry._id,{conflicts:true});", + "const conflictRevs=current._conflicts||[];", + "if(conflictRevs.length!==1)throw new Error(`Expected one competing hidden leaf before replacement: ${path}`);", + "const conflicts=await Promise.all(conflictRevs.map(rev=>db.getDBEntry(`i:${path}`,{rev},false,true,true)));", + "if(conflicts.some(doc=>!doc))throw new Error(`Could not read the competing hidden revision: ${path}`);", + "const mtime=Math.max(Number(entry.mtime)||0,...conflicts.map(doc=>Number(doc.mtime)||0))+10;", + "const fileInfo=await addOn.loadFileWithInfo(path);", + "if(fileInfo.deleted)throw new Error(`Hidden replacement file was unexpectedly deleted: ${path}`);", + "const baseData=await addOn.__loadBaseSaveData(path,true);", + "if(baseData===false)throw new Error(`Could not load hidden replacement save data: ${path}`);", + "const saveData={...baseData,data:fileInfo.body,mtime,ctime:fileInfo.stat.ctime,size:fileInfo.stat.size,children:[],deleted:false,type:baseData.datatype};", + "const result=await db.putDBEntry(saveData,false,entry._rev);", + "if(!result?.ok)throw new Error(`Could not advance the current hidden conflict winner: ${path}`);", + "return JSON.stringify({rev:result.rev});", + "})()", + ].join(""), + session.cliEnv + ); + const beforeQueue = await readHiddenConflictLeaves(context.cliBinary, session.cliEnv, replacementMergeJsonPath); + assertEqual( + beforeQueue.currentRev, + updatedWinner.rev, + "The latest hidden conflict revision is not the current winner." + ); + assertEqual( + beforeQueue.conflictRevs.length, + 1, + "Advancing the current winner changed the competing hidden leaf count." + ); + assertEqual( + beforeQueue.leaves.length, + 2, + "Advancing the current winner did not retain both hidden conflict leaves." + ); + const beforeQueueContents = beforeQueue.leaves.map(decodeHiddenConflictLeaf); + assertEqual( + beforeQueueContents.includes(latest), + true, + "The current hidden winner does not contain the latest JSON content." + ); + assertEqual( + beforeQueueContents.some((content) => content === left || content === right), + true, + "The competing hidden leaf content is not readable after advancing the current winner." + ); + + await evalObsidianJson( + context.cliBinary, + [ + "(()=>{", + `const path=${JSON.stringify(replacementMergeJsonPath)};`, + "const addOn=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');", + "addOn.queueConflictCheck(path);", + "return JSON.stringify({queued:true});", + "})()", + ].join(""), + session.cliEnv + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.waitForFunction( + (key) => { + const state = (globalThis as unknown as Record)[key]; + if (!state || state.firstModal.isConnected) return false; + const visibleLabels = Array.from( + document.querySelectorAll('label:has(input[name="disp"])') + ).filter( + (label) => label.getClientRects().length > 0 && getComputedStyle(label).visibility !== "hidden" + ); + const roots = new Set(visibleLabels.map((label) => label.closest(".modal-container"))); + return visibleLabels.length >= 2 && roots.size === 1; + }, + hiddenConflictModalStateKey, + { timeout: 10000 } + ); + const visibleDialogueCount = await page.evaluate(() => { + const visibleLabels = Array.from( + document.querySelectorAll('label:has(input[name="disp"])') + ).filter((label) => label.getClientRects().length > 0 && getComputedStyle(label).visibility !== "hidden"); + return new Set(visibleLabels.map((label) => label.closest(".modal-container"))).size; + }); + assertEqual( + visibleDialogueCount, + 1, + "Replacing a hidden comparison left more than one JSON conflict dialogue visible." + ); + }); + + const replacementDialogueState = await readHiddenJsonComparisonObservation(context.cliBinary, session.cliEnv); + assertEqual( + replacementDialogueState.pipelineDone, + false, + "The hidden conflict pipeline completed before the replacement dialogue was applied." + ); + assertEqual( + replacementDialogueState.calls.length, + 2, + "The latest hidden conflict request did not open a second comparison." + ); + assertEqual( + replacementDialogueState.calls[0].done, + true, + "The first hidden JSON comparison did not complete when replaced." + ); + assertEqual(replacementDialogueState.calls[0].result, false, "The first hidden JSON comparison was not cancelled."); + assertEqual( + [replacementDialogueState.calls[1].leftRev, replacementDialogueState.calls[1].rightRev].includes( + updatedWinner.rev + ), + true, + "The replacement JSON comparison did not include the latest current revision." + ); + assertEqual( + replacementDialogueState.calls[1].done, + false, + "The replacement JSON comparison completed before the user selected it." + ); + + const afterCancellation = await readHiddenConflictLeaves( + context.cliBinary, + session.cliEnv, + replacementMergeJsonPath + ); + assertEqual( + afterCancellation.currentRev, + updatedWinner.rev, + "Cancelling the first dialogue changed the latest current revision." + ); + assertEqual( + afterCancellation.conflictRevs.length, + 1, + "Cancelling the first dialogue removed the competing hidden leaf." + ); + assertEqual( + afterCancellation.leaves.length, + 2, + "Cancelling the first dialogue did not preserve both hidden conflict leaves." + ); + const afterCancellationContents = afterCancellation.leaves.map(decodeHiddenConflictLeaf); + assertEqual( + afterCancellationContents.includes(latest), + true, + "The latest hidden content became unreadable after cancelling the first dialogue." + ); + assertEqual( + afterCancellationContents.some((content) => content === left || content === right), + true, + "The competing hidden content became unreadable after cancelling the first dialogue." + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const optionAB = page.locator('label:has(input[name="disp"][value="AB"])').last(); + await optionAB.waitFor({ state: "visible", timeout: 10000 }); + await optionAB.click(); + const selected = await page.locator('input[name="disp"][value="AB"]').last().isChecked(); + assertEqual(selected, true, "The replacement JSON dialogue did not select AB."); + await page.waitForFunction( + (marker) => { + const options = Array.from( + document.querySelectorAll('input[name="disp"][value="AB"]') + ); + const option = options + .reverse() + .find((candidate) => (candidate.closest("label")?.getClientRects().length ?? 0) > 0); + const modal = option?.closest(".modal-container"); + if (!(modal instanceof HTMLElement)) return false; + const values = Array.from( + modal.querySelectorAll("input,textarea") + ).map((element) => element.value); + return `${modal.innerText}\n${values.join("\n")}`.includes(marker); + }, + latestMarker, + { timeout: 10000 } + ); + await page.getByRole("button", { name: "Apply" }).last().click(); + }); + await waitForHiddenConflictResolution(context.cliBinary, session.cliEnv); + + const resolved = await waitForPathContent(vault.path, replacementMergeJsonPath, (content) => + hasJsonValues(content, { shared: "latest", fromA: true, fromB: true, latestMarker }) + ); + await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, replacementMergeJsonPath, resolved); + const parsed = JSON.parse(resolved) as Record; + assertEqual(parsed.latestMarker, latestMarker, "The applied JSON result lost the latest replacement marker."); + + const finalDialogueState = await readHiddenJsonComparisonObservation(context.cliBinary, session.cliEnv); + assertEqual(finalDialogueState.pipelineDone, true, "The replacement hidden conflict pipeline did not finish."); + assertEqual( + finalDialogueState.calls[0].result, + false, + "The replaced hidden JSON comparison did not remain cancelled." + ); + assertEqual(finalDialogueState.calls[1].done, true, "The replacement hidden JSON comparison did not complete."); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const finalState = await page.evaluate((key) => { + const state = (globalThis as unknown as Record)[key]; + const visibleLabels = Array.from( + document.querySelectorAll('label:has(input[name="disp"])') + ).filter((label) => label.getClientRects().length > 0 && getComputedStyle(label).visibility !== "hidden"); + return { + firstConnected: state?.firstModal.isConnected ?? false, + visibleDialogueCount: new Set(visibleLabels.map((label) => label.closest(".modal-container"))).size, + }; + }, hiddenConflictModalStateKey); + assertEqual( + finalState.firstConnected, + false, + "The replaced JSON conflict dialogue remained connected after resolution." + ); + assertEqual( + finalState.visibleDialogueCount, + 0, + "A stale JSON conflict dialogue remained open after resolution." + ); + }); + + await stopSession(context, session); + console.log( + "Hidden JSON conflict replacement cancelled the stale dialogue and applied the latest revision and contents." + ); +} + +async function runJsonManualExternalResolution(context: RunnerContext, vault: TemporaryVault): Promise { + const base = JSON.stringify({ shared: "base" }, null, 4) + "\n"; + const left = JSON.stringify({ shared: "left", fromA: true }, null, 4) + "\n"; + const right = JSON.stringify({ shared: "right", fromB: true }, null, 4) + "\n"; + const unrelatedContent = JSON.stringify({ externalResolution: "unrelated" }, null, 4) + "\n"; + const session = await startConfiguredSession(context, vault); + + await writeVaultFile(vault.path, externalResolutionUnrelatedPath, unrelatedContent); + await scanHiddenStorage(context.cliBinary, session.cliEnv); + const unrelatedEntry = await waitForLocalDatabaseEntry( + context.cliBinary, + session.cliEnv, + externalResolutionUnrelatedPath, + { + hidden: true, + } + ); + await createHiddenJsonConflict(context, session, vault, externalResolutionTargetPath, base, left, right); + const initialLeaves = await readHiddenConflictLeaves( + context.cliBinary, + session.cliEnv, + externalResolutionTargetPath + ); + assertEqual( + initialLeaves.conflictRevs.length, + 1, + "The external-resolution target did not have one competing leaf." + ); + assertEqual(initialLeaves.leaves.length, 2, "The external-resolution target did not retain two live leaves."); + const winnerContent = decodeHiddenConflictLeaf( + initialLeaves.leaves.find((leaf) => leaf.rev === initialLeaves.currentRev) ?? initialLeaves.leaves[0] + ); + assertEqual( + [left, right].includes(winnerContent), + true, + "The initial hidden conflict winner content is not readable." + ); + await writeVaultFile(vault.path, externalResolutionTargetPath, winnerContent); + + await installHiddenJsonComparisonObserver(context.cliBinary, session.cliEnv); + await startHiddenConflictResolution(context.cliBinary, session.cliEnv); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const firstOption = page.locator('label:has(input[name="disp"][value="AB"])').last(); + await firstOption.waitFor({ state: "visible", timeout: 10000 }); + await firstOption.evaluate((element, key) => { + const firstModal = element.closest(".modal-container"); + if (!(firstModal instanceof HTMLElement)) + throw new Error("The external-resolution JSON dialogue has no modal container"); + (globalThis as unknown as Record)[key] = { firstModal }; + }, hiddenConflictModalStateKey); + }); + + const initialObservation = await readHiddenJsonComparisonObservation(context.cliBinary, session.cliEnv); + assertEqual( + initialObservation.pipelineDone, + false, + "The external-resolution conflict pipeline completed before a result arrived." + ); + assertEqual( + initialObservation.calls.length, + 1, + "The external-resolution target did not open exactly one comparison." + ); + assertEqual( + initialObservation.calls[0].done, + false, + "The external-resolution dialogue completed before a result arrived." + ); + + const unrelatedResolution = await evalObsidianJson<{ rev: string }>( + context.cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(externalResolutionUnrelatedPath)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const meta=await core.localDatabase.getDBEntryMeta(`i:${path}`,{conflicts:true},true);", + "if(!meta)throw new Error(`Unrelated hidden metadata is missing: ${path}`);", + "if((meta._conflicts||[]).length!==0)throw new Error(`Unrelated hidden metadata unexpectedly has conflicts: ${path}`);", + "await core.services.replication.processOptionalSynchroniseResult(meta);", + "return JSON.stringify({rev:meta._rev});", + "})()", + ].join(""), + session.cliEnv, + hiddenFileCliTimeoutMs + ); + assertEqual( + unrelatedResolution.rev, + unrelatedEntry.rev, + "The unrelated hidden fixture did not have the expected single revision." + ); + const afterUnrelatedResult = await readHiddenJsonComparisonObservation(context.cliBinary, session.cliEnv); + assertEqual( + afterUnrelatedResult.pipelineDone, + false, + "A result for another hidden path completed the target conflict pipeline." + ); + assertEqual(afterUnrelatedResult.calls.length, 1, "A result for another path opened another target comparison."); + assertEqual( + afterUnrelatedResult.calls[0].done, + false, + "A result for another path closed the target JSON comparison." + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modalState = await page.evaluate((key) => { + const state = (globalThis as unknown as Record)[key]; + const visibleLabels = Array.from( + document.querySelectorAll('label:has(input[name="disp"])') + ).filter((label) => label.getClientRects().length > 0 && getComputedStyle(label).visibility !== "hidden"); + return { + firstConnected: state?.firstModal.isConnected ?? false, + visibleDialogueCount: new Set(visibleLabels.map((label) => label.closest(".modal-container"))).size, + }; + }, hiddenConflictModalStateKey); + assertEqual(modalState.firstConnected, true, "An unrelated hidden result detached the target JSON dialogue."); + assertEqual( + modalState.visibleDialogueCount, + 1, + "An unrelated hidden result changed the visible target dialogue count." + ); + }); + + const targetResolution = await evalObsidianJson<{ winnerRev: string; removedRev: string }>( + context.cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(externalResolutionTargetPath)};`, + "const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase;", + "const entry=await db.getDBEntry(`i:${path}`);", + "if(!entry)throw new Error(`Hidden conflict entry is missing: ${path}`);", + "const current=await db.getRaw(entry._id,{conflicts:true});", + "const conflictRevs=current._conflicts||[];", + "if(conflictRevs.length!==1)throw new Error(`Expected one hidden conflict leaf before external resolution: ${path}`);", + "const removedRev=conflictRevs[0];", + "await db.removeRevision(entry._id,removedRev);", + "const meta=await db.getDBEntryMeta(`i:${path}`,{conflicts:true},true);", + "if(!meta)throw new Error(`Resolved hidden metadata is missing: ${path}`);", + "if((meta._conflicts||[]).length!==0)throw new Error(`External hidden resolution still has conflicts: ${path}`);", + "await app.plugins.plugins['obsidian-livesync'].core.services.replication.processOptionalSynchroniseResult(meta);", + "return JSON.stringify({winnerRev:meta._rev,removedRev});", + "})()", + ].join(""), + session.cliEnv, + hiddenFileCliTimeoutMs + ); + await waitForHiddenConflictResolution(context.cliBinary, session.cliEnv); + + const resolvedLeaves = await readHiddenConflictLeaves( + context.cliBinary, + session.cliEnv, + externalResolutionTargetPath + ); + assertEqual( + resolvedLeaves.currentRev, + targetResolution.winnerRev, + "External hidden resolution changed the selected winner revision." + ); + assertEqual( + resolvedLeaves.currentRev, + initialLeaves.currentRev, + "External hidden resolution changed the current winner revision." + ); + assertEqual( + targetResolution.removedRev, + initialLeaves.conflictRevs[0], + "External hidden resolution removed a different leaf than the competing revision." + ); + assertEqual( + resolvedLeaves.conflictRevs.length, + 0, + "The externally resolved hidden conflict remains in the database." + ); + assertEqual( + resolvedLeaves.leaves.length, + 1, + "External hidden resolution did not leave exactly one readable revision." + ); + assertEqual( + decodeHiddenConflictLeaf(resolvedLeaves.leaves[0]), + winnerContent, + "External hidden resolution changed the current winner bytes." + ); + const vaultWinner = await waitForPathContent( + vault.path, + externalResolutionTargetPath, + (content) => content === winnerContent + ); + await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, externalResolutionTargetPath, vaultWinner); + + const finalObservation = await readHiddenJsonComparisonObservation(context.cliBinary, session.cliEnv); + assertEqual(finalObservation.pipelineDone, true, "The externally resolved hidden conflict pipeline did not drain."); + assertEqual(finalObservation.calls.length, 1, "External resolution opened a second JSON comparison."); + assertEqual(finalObservation.calls[0].done, true, "The external-resolution JSON comparison did not complete."); + assertEqual(finalObservation.calls[0].result, false, "The external-resolution JSON dialogue was not cancelled."); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modalState = await page.evaluate((key) => { + const state = (globalThis as unknown as Record)[key]; + const visibleLabels = Array.from( + document.querySelectorAll('label:has(input[name="disp"])') + ).filter((label) => label.getClientRects().length > 0 && getComputedStyle(label).visibility !== "hidden"); + return { + firstConnected: state?.firstModal.isConnected ?? false, + visibleDialogueCount: new Set(visibleLabels.map((label) => label.closest(".modal-container"))).size, + }; + }, hiddenConflictModalStateKey); + assertEqual(modalState.firstConnected, false, "The externally resolved JSON dialogue remained connected."); + assertEqual( + modalState.visibleDialogueCount, + 0, + "The old hidden conflict dialogue reopened after external resolution." + ); + }); + + await stopSession(context, session); + console.log( + "Hidden JSON conflict dialogue closed for an external same-path resolution and preserved the selected bytes." + ); +} + async function runTargetMismatch( context: RunnerContext, vaultA: TemporaryVault, @@ -1261,10 +1855,18 @@ async function runHiddenEnablement(context: RunnerContext, mode: HiddenEnablemen async function main(): Promise { const selectedCase = process.argv.find((argument) => argument.startsWith("--case="))?.slice(7); - if (selectedCase && selectedCase !== "conflicts" && selectedCase !== "enablement") { - throw new Error("Use --case=conflicts or --case=enablement."); + if ( + selectedCase && + selectedCase !== "conflicts" && + selectedCase !== "replacement" && + selectedCase !== "external-resolution" && + selectedCase !== "enablement" + ) { + throw new Error("Use --case=conflicts, --case=replacement, --case=external-resolution, or --case=enablement."); } const conflictsOnly = selectedCase === "conflicts"; + const replacementOnly = selectedCase === "replacement"; + const externalResolutionOnly = selectedCase === "external-resolution"; const enablementOnly = selectedCase === "enablement"; const selectedMode = process.argv.find((argument) => argument.startsWith("--mode="))?.slice(7); const enablementModes = (["Merge", "Fetch", "Overwrite"] as const).filter( @@ -1290,7 +1892,14 @@ async function main(): Promise { dbName, activeSessions: new Set(), temporaryVaults: new Set([vaultA, vaultB]), - diagnosticPaths: [mergeJsonPath, manualMergeJsonPath, snippetPath], + diagnosticPaths: [ + mergeJsonPath, + manualMergeJsonPath, + replacementMergeJsonPath, + externalResolutionTargetPath, + externalResolutionUnrelatedPath, + snippetPath, + ], }; try { @@ -1302,20 +1911,26 @@ async function main(): Promise { console.log(`Temporary vault B: ${vaultB.path}`); console.log(`Temporary CouchDB database: ${dbName}`); - if (!conflictsOnly && !enablementOnly) { + if (!conflictsOnly && !replacementOnly && !externalResolutionOnly && !enablementOnly) { await runCreateRoundTrip(context, vaultA, vaultB); await runDeleteRoundTrip(context, vaultA, vaultB); } - if (!enablementOnly) { + if (!enablementOnly && !replacementOnly && !externalResolutionOnly) { await runJsonConflictRoundTrip(context, vaultA, vaultB); await runJsonManualConflictResolution(context, vaultB); } - if (!conflictsOnly && !enablementOnly) { + if (!enablementOnly && !externalResolutionOnly) { + await runJsonManualConflictReplacement(context, vaultB); + } + if (!enablementOnly && !replacementOnly) { + await runJsonManualExternalResolution(context, vaultB); + } + if (!conflictsOnly && !replacementOnly && !externalResolutionOnly && !enablementOnly) { await runTargetMismatch(context, vaultA, vaultB); await runInitialisationNoticeGrouping(context, vaultB); await runConfigurationNoticeGrouping(context, vaultB); } - if (!conflictsOnly) { + if (!conflictsOnly && !replacementOnly && !externalResolutionOnly) { for (const mode of enablementModes) await runHiddenEnablement(context, mode); } } catch (error) { diff --git a/updates.md b/updates.md index 6d31210f..c267077b 100644 --- a/updates.md +++ b/updates.md @@ -24,6 +24,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi - Hidden File Sync now stores newer local files during `Merge` initialisation by comparing local and database modification times in the correct direction. +- Hidden File Sync refreshes an open manual JSON comparison when a later conflict check arrives for the same file. + - The earlier dialogue closes, and the latest revisions are checked again before another comparison opens. A replicated resolution also closes the affected comparison. Dialogues for other files keep their order. + - An `Apply` operation already in progress finishes before its replacement proceeds. + - Reloading the Customisation Sync catalogue no longer allows an earlier legacy-format scan to put outdated items back into the list. - Reloading or disabling Customisation Sync discards unstarted scans and ignores stale read results. Migrations to per-file storage which have already started are allowed to finish.