From 97d944fd75007a427242cfa8b7f63abe3b65ce8d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 16 Jan 2024 08:32:43 +0000 Subject: [PATCH] New feature: - We can perform automatic conflict resolution for inactive files, and postpone only manual ones by `Postpone manual resolution of inactive files`. - Now we can see the image in the document history dialogue. - We can see the difference of the image, in the document history dialogue. - And also we can highlight differences. Improved: - Hidden file sync has been stabilised. - Now automatically reloads the conflict-resolution dialogue when new conflicted revisions have arrived. Fixed: - No longer periodic process runs after unloading the plug-in. - Now the modification of binary files is surely stored in the storage. --- src/CmdHiddenFileSync.ts | 71 +++++--- src/ConflictResolveModal.ts | 82 +++++---- src/DocumentHistoryModal.ts | 116 ++++++++++--- src/JsonResolveModal.ts | 2 + src/ObsidianLiveSyncSettingTab.ts | 13 +- src/SerializedFileAccess.ts | 2 +- src/lib | 2 +- src/main.ts | 273 +++++++++++++++--------------- src/utils.ts | 22 ++- styles.css | 50 +++++- 10 files changed, 390 insertions(+), 243 deletions(-) diff --git a/src/CmdHiddenFileSync.ts b/src/CmdHiddenFileSync.ts index 2f7b354..c490a43 100644 --- a/src/CmdHiddenFileSync.ts +++ b/src/CmdHiddenFileSync.ts @@ -1,7 +1,7 @@ import { normalizePath, type PluginManifest } from "./deps"; import { type EntryDoc, type LoadedEntry, type InternalFileEntry, type FilePathWithPrefix, type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MODE_SELECTIVE, MODE_PAUSED, type SavingEntry } from "./lib/src/types"; import { type InternalFileInfo, ICHeader, ICHeaderEnd } from "./types"; -import { createBinaryBlob, delay, isDocContentSame } from "./lib/src/utils"; +import { createBinaryBlob, isDocContentSame, sendSignal } from "./lib/src/utils"; import { Logger } from "./lib/src/logger"; import { PouchDB } from "./lib/src/pouchdb-browser.js"; import { isInternalMetadata, PeriodicProcessor } from "./utils"; @@ -86,7 +86,7 @@ export class HiddenFileSync extends LiveSyncCommands { await this.syncInternalFilesAndDatabase("pull", false, false, filenames); Logger(`DONE :Applying hidden ${filenames.length} files change`, LOG_LEVEL_VERBOSE); return; - }, { batchSize: 100, concurrentLimit: 1, delay: 100, yieldThreshold: 10, suspended: false, totalRemainingReactiveSource: hiddenFilesEventCount } + }, { batchSize: 100, concurrentLimit: 1, delay: 10, yieldThreshold: 10, suspended: false, totalRemainingReactiveSource: hiddenFilesEventCount } ); recentProcessedInternalFiles = [] as string[]; @@ -134,25 +134,34 @@ export class HiddenFileSync extends LiveSyncCommands { async resolveConflictOnInternalFiles() { // Scan all conflicted internal files const conflicted = this.localDatabase.findEntries(ICHeader, ICHeaderEnd, { conflicts: true }); - for await (const doc of conflicted) { - if (!("_conflicts" in doc)) - continue; - if (isInternalMetadata(doc._id)) { - await this.resolveConflictOnInternalFile(doc.path); + this.conflictResolutionProcessor.suspend(); + try { + for await (const doc of conflicted) { + if (!("_conflicts" in doc)) + continue; + if (isInternalMetadata(doc._id)) { + this.conflictResolutionProcessor.enqueue(doc.path); + } } + } catch (ex) { + Logger("something went wrong on resolving all conflicted internal files"); + Logger(ex, LOG_LEVEL_VERBOSE); } + await this.conflictResolutionProcessor.startPipeline().waitForPipeline(); } - async resolveConflictOnInternalFile(path: FilePathWithPrefix): Promise { + conflictResolutionProcessor = new QueueProcessor(async (paths: FilePathWithPrefix[]) => { + const path = paths[0]; + sendSignal(`cancel-internal-conflict:${path}`); try { // Retrieve data const id = await this.path2id(path, ICHeader); const doc = await this.localDatabase.getRaw(id, { conflicts: true }); // If there is no conflict, return with false. if (!("_conflicts" in doc)) - return false; + return; if (doc._conflicts.length == 0) - return false; + return; Logger(`Hidden file conflicted:${path}`); const conflicts = doc._conflicts.sort((a, b) => Number(a.split("-")[0]) - Number(b.split("-")[0])); const revA = doc._rev; @@ -177,21 +186,12 @@ export class HiddenFileSync extends LiveSyncCommands { await this.storeInternalFileToDatabase({ path: filename, ...stat }); await this.extractInternalFileFromDatabase(filename); await this.localDatabase.removeRaw(id, revB); - return this.resolveConflictOnInternalFile(path); + this.conflictResolutionProcessor.enqueue(path); + return; } else { Logger(`Object merge is not applicable.`, LOG_LEVEL_VERBOSE); } - - const docAMerge = await this.localDatabase.getDBEntry(path, { rev: revA }); - const docBMerge = await this.localDatabase.getDBEntry(path, { rev: revB }); - if (docAMerge != false && docBMerge != false) { - if (await this.showJSONMergeDialogAndMerge(docAMerge, docBMerge)) { - await delay(200); - // Again for other conflicted revisions. - return this.resolveConflictOnInternalFile(path); - } - return false; - } + return [{ path, revA, revB }]; } const revBDoc = await this.localDatabase.getRaw(id, { rev: revB }); // determine which revision should been deleted. @@ -205,12 +205,31 @@ export class HiddenFileSync extends LiveSyncCommands { await this.localDatabase.removeRaw(id, delRev); Logger(`Older one has been deleted:${path}`); // check the file again - return this.resolveConflictOnInternalFile(path); + this.conflictResolutionProcessor.enqueue(path); + return; } catch (ex) { Logger(`Failed to resolve conflict (Hidden): ${path}`); Logger(ex, LOG_LEVEL_VERBOSE); - return false; + return; } + }, { + suspended: false, batchSize: 1, concurrentLimit: 5, delay: 10, keepResultUntilDownstreamConnected: true, yieldThreshold: 10, + pipeTo: new QueueProcessor(async (results) => { + const { path, revA, revB } = results[0] + const docAMerge = await this.localDatabase.getDBEntry(path, { rev: revA }); + const docBMerge = await this.localDatabase.getDBEntry(path, { rev: revB }); + if (docAMerge != false && docBMerge != false) { + if (await this.showJSONMergeDialogAndMerge(docAMerge, docBMerge)) { + // Again for other conflicted revisions. + this.conflictResolutionProcessor.enqueue(path); + } + return; + } + }, { suspended: false, batchSize: 1, concurrentLimit: 1, delay: 10, keepResultUntilDownstreamConnected: false, yieldThreshold: 10 }) + }) + + queueConflictCheck(path: FilePathWithPrefix) { + this.conflictResolutionProcessor.enqueue(path); } //TODO: Tidy up. Even though it is experimental feature, So dirty... @@ -595,7 +614,7 @@ export class HiddenFileSync extends LiveSyncCommands { showJSONMergeDialogAndMerge(docA: LoadedEntry, docB: LoadedEntry): Promise { - return serialized("conflict:merge-data", () => new Promise((res) => { + return new Promise((res) => { Logger("Opening data-merging dialog", LOG_LEVEL_VERBOSE); const docs = [docA, docB]; const path = stripAllPrefixes(docA.path); @@ -649,7 +668,7 @@ export class HiddenFileSync extends LiveSyncCommands { } }); modal.open(); - })); + }); } async scanInternalFiles(): Promise { diff --git a/src/ConflictResolveModal.ts b/src/ConflictResolveModal.ts index df28afb..cc5f61a 100644 --- a/src/ConflictResolveModal.ts +++ b/src/ConflictResolveModal.ts @@ -1,23 +1,41 @@ import { App, Modal } from "./deps"; import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch"; -import { type diff_result } from "./lib/src/types"; +import { CANCELLED, LEAVE_TO_SUBSEQUENT, RESULT_TIMED_OUT, type diff_result } from "./lib/src/types"; import { escapeStringToHTML } from "./lib/src/strbin"; +import { delay, sendValue, waitForValue } from "./lib/src/utils"; +export type MergeDialogResult = typeof LEAVE_TO_SUBSEQUENT | typeof CANCELLED | string; export class ConflictResolveModal extends Modal { - // result: Array<[number, string]>; result: diff_result; filename: string; - callback: (remove_rev: string) => Promise; - constructor(app: App, filename: string, diff: diff_result, callback: (remove_rev: string) => Promise) { + response: MergeDialogResult = CANCELLED; + isClosed = false; + consumed = false; + + constructor(app: App, filename: string, diff: diff_result) { super(app); this.result = diff; - this.callback = callback; this.filename = filename; + // Send cancel signal for the previous merge dialogue + // if not there, simply be ignored. + // sendValue("close-resolve-conflict:" + this.filename, false); + sendValue("cancel-resolve-conflict:" + this.filename, true); } onOpen() { const { contentEl } = this; + // Send cancel signal for the previous merge dialogue + // if not there, simply be ignored. + sendValue("cancel-resolve-conflict:" + this.filename, true); + setTimeout(async () => { + const forceClose = await waitForValue("cancel-resolve-conflict:" + this.filename); + // debugger; + if (forceClose) { + this.sendResponse(CANCELLED); + } + }, 10) + // sendValue("close-resolve-conflict:" + this.filename, false); this.titleEl.setText("Conflicting changes"); contentEl.empty(); contentEl.createEl("span", { text: this.filename }); @@ -44,42 +62,32 @@ export class ConflictResolveModal extends Modal { div2.innerHTML = ` A:${date1}
B:${date2}
`; - contentEl.createEl("button", { text: "Keep A" }, (e) => { - e.addEventListener("click", async () => { - const callback = this.callback; - this.callback = null; - this.close(); - await callback(this.result.right.rev); - }); - }); - contentEl.createEl("button", { text: "Keep B" }, (e) => { - e.addEventListener("click", async () => { - const callback = this.callback; - this.callback = null; - this.close(); - await callback(this.result.left.rev); - }); - }); - contentEl.createEl("button", { text: "Concat both" }, (e) => { - e.addEventListener("click", async () => { - const callback = this.callback; - this.callback = null; - this.close(); - await callback(""); - }); - }); - contentEl.createEl("button", { text: "Not now" }, (e) => { - e.addEventListener("click", () => { - this.close(); - }); - }); + contentEl.createEl("button", { text: "Keep A" }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.right.rev))); + contentEl.createEl("button", { text: "Keep B" }, (e) => e.addEventListener("click", () => this.sendResponse(this.result.left.rev))); + contentEl.createEl("button", { text: "Concat both" }, (e) => e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT))); + contentEl.createEl("button", { text: "Not now" }, (e) => e.addEventListener("click", () => this.sendResponse(CANCELLED))); + } + + sendResponse(result: MergeDialogResult) { + this.response = result; + this.close(); } onClose() { const { contentEl } = this; contentEl.empty(); - if (this.callback != null) { - this.callback(null); + if (this.consumed) { + return; } + this.consumed = true; + sendValue("close-resolve-conflict:" + this.filename, this.response); + sendValue("cancel-resolve-conflict:" + this.filename, false); } -} + + async waitForResult(): Promise { + await delay(100); + const r = await waitForValue("close-resolve-conflict:" + this.filename); + if (r === RESULT_TIMED_OUT) return CANCELLED; + return r; + } +} \ No newline at end of file diff --git a/src/DocumentHistoryModal.ts b/src/DocumentHistoryModal.ts index 2880d01..065fed6 100644 --- a/src/DocumentHistoryModal.ts +++ b/src/DocumentHistoryModal.ts @@ -6,8 +6,34 @@ import { type DocumentID, type FilePathWithPrefix, type LoadedEntry, LOG_LEVEL_I import { Logger } from "./lib/src/logger"; import { isErrorOfMissingDoc } from "./lib/src/utils_couchdb"; import { getDocData } from "./lib/src/utils"; -import { stripPrefix } from "./lib/src/path"; +import { isPlainText, stripPrefix } from "./lib/src/path"; +function isImage(path: string) { + const ext = path.split(".").splice(-1)[0].toLowerCase(); + return ["png", "jpg", "jpeg", "gif", "bmp", "webp"].includes(ext); +} +function isComparableText(path: string) { + const ext = path.split(".").splice(-1)[0].toLowerCase(); + return isPlainText(path) || ["md", "mdx", "txt", "json"].includes(ext); +} +function isComparableTextDecode(path: string) { + const ext = path.split(".").splice(-1)[0].toLowerCase(); + return ["json"].includes(ext) +} +function readDocument(w: LoadedEntry) { + if (isImage(w.path)) { + return new Uint8Array(decodeBinary(w.data)); + } + if (w.data == "plain") return getDocData(w.data); + if (isComparableTextDecode(w.path)) return readString(new Uint8Array(decodeBinary(w.data))); + if (isComparableText(w.path)) return getDocData(w.data); + try { + return readString(new Uint8Array(decodeBinary(w.data))); + } catch (ex) { + // NO OP. + } + return getDocData(w.data); +} export class DocumentHistoryModal extends Modal { plugin: ObsidianLiveSyncPlugin; range!: HTMLInputElement; @@ -56,7 +82,6 @@ export class DocumentHistoryModal extends Modal { this.range.max = "0"; this.range.value = ""; this.range.disabled = true; - this.showDiff this.contentView.setText(`History of this file was not recorded.`); } else { this.contentView.setText(`Error occurred.`); @@ -76,6 +101,22 @@ export class DocumentHistoryModal extends Modal { const rev = this.revs_info[index]; await this.showExactRev(rev.rev); } + BlobURLs = new Map(); + + revokeURL(key: string) { + const v = this.BlobURLs.get(key); + if (v) { + URL.revokeObjectURL(v); + } + this.BlobURLs.set(key, undefined); + } + generateBlobURL(key: string, data: Uint8Array) { + this.revokeURL(key); + const v = URL.createObjectURL(new Blob([data], { endings: "transparent", type: "application/octet-stream" })); + this.BlobURLs.set(key, v); + return v; + } + async showExactRev(rev: string) { const db = this.plugin.localDatabase; const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true); @@ -88,42 +129,67 @@ export class DocumentHistoryModal extends Modal { } else { this.currentDoc = w; this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`; - let result = ""; - const w1data = w.datatype == "plain" ? getDocData(w.data) : readString(new Uint8Array(decodeBinary(w.data))); + let result = undefined; + const w1data = readDocument(w); this.currentDeleted = !!w.deleted; - this.currentText = w1data; + // this.currentText = w1data; if (this.showDiff) { const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1); if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) { const oldRev = this.revs_info[prevRevIdx].rev; const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true); if (w2 != false) { - const dmp = new diff_match_patch(); - const w2data = w2.datatype == "plain" ? getDocData(w2.data) : readString(new Uint8Array(decodeBinary(w2.data))); - const diff = dmp.diff_main(w2data, w1data); - dmp.diff_cleanupSemantic(diff); - for (const v of diff) { - const x1 = v[0]; - const x2 = v[1]; - if (x1 == DIFF_DELETE) { - result += "" + escapeStringToHTML(x2) + ""; - } else if (x1 == DIFF_EQUAL) { - result += "" + escapeStringToHTML(x2) + ""; - } else if (x1 == DIFF_INSERT) { - result += "" + escapeStringToHTML(x2) + ""; + if (typeof w1data == "string") { + result = ""; + const dmp = new diff_match_patch(); + const w2data = readDocument(w2) as string; + const diff = dmp.diff_main(w2data, w1data); + dmp.diff_cleanupSemantic(diff); + for (const v of diff) { + const x1 = v[0]; + const x2 = v[1]; + if (x1 == DIFF_DELETE) { + result += "" + escapeStringToHTML(x2) + ""; + } else if (x1 == DIFF_EQUAL) { + result += "" + escapeStringToHTML(x2) + ""; + } else if (x1 == DIFF_INSERT) { + result += "" + escapeStringToHTML(x2) + ""; + } } + result = result.replace(/\n/g, "
"); + } else if (isImage(this.file)) { + const src = this.generateBlobURL("base", w1data); + const overlay = this.generateBlobURL("overlay", readDocument(w2) as Uint8Array); + result = + `
+
+ + +
+
`; + this.contentView.removeClass("op-pre"); } + } + } - result = result.replace(/\n/g, "
"); - } else { - result = escapeStringToHTML(w1data); + } + if (result == undefined) { + if (typeof w1data != "string") { + if (isImage(this.file)) { + const src = this.generateBlobURL("base", w1data); + result = + `
+
+ +
+
`; + this.contentView.removeClass("op-pre"); } } else { result = escapeStringToHTML(w1data); } - } else { - result = escapeStringToHTML(w1data); } + if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file"; this.contentView.innerHTML = (this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result; } } @@ -217,5 +283,9 @@ export class DocumentHistoryModal extends Modal { onClose() { const { contentEl } = this; contentEl.empty(); + this.BlobURLs.forEach(value => { + console.log(value); + if (value) URL.revokeObjectURL(value); + }) } } diff --git a/src/JsonResolveModal.ts b/src/JsonResolveModal.ts index 64d68ef..5bebee4 100644 --- a/src/JsonResolveModal.ts +++ b/src/JsonResolveModal.ts @@ -1,6 +1,7 @@ import { App, Modal } from "./deps"; import { type FilePath, type LoadedEntry } from "./lib/src/types"; import JsonResolvePane from "./JsonResolvePane.svelte"; +import { waitForSignal } from "./lib/src/utils"; export class JsonResolveModal extends Modal { // result: Array<[number, string]>; @@ -20,6 +21,7 @@ export class JsonResolveModal extends Modal { this.nameA = nameA; this.nameB = nameB; this.defaultSelect = defaultSelect; + waitForSignal(`cancel-internal-conflict:${filename}`).then(() => this.close()); } async UICallback(keepRev: string, mergedStr?: string) { this.close(); diff --git a/src/ObsidianLiveSyncSettingTab.ts b/src/ObsidianLiveSyncSettingTab.ts index 6d97703..5dd10c6 100644 --- a/src/ObsidianLiveSyncSettingTab.ts +++ b/src/ObsidianLiveSyncSettingTab.ts @@ -1116,7 +1116,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { ); new Setting(containerSyncSettingEl) - .setName("Postpone resolution of unopened files") + .setName("Postpone resolution of inactive files") .setClass("wizardHidden") .addToggle((toggle) => toggle.setValue(this.plugin.settings.checkConflictOnlyOnOpen).onChange(async (value) => { @@ -1124,6 +1124,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }) ); + new Setting(containerSyncSettingEl) + .setName("Postpone manual resolution of inactive files") + .setClass("wizardHidden") + .addToggle((toggle) => + toggle.setValue(this.plugin.settings.showMergeDialogOnlyOnActive).onChange(async (value) => { + this.plugin.settings.showMergeDialogOnlyOnActive = value; + await this.plugin.saveSettings(); + }) + ); containerSyncSettingEl.createEl("h4", { text: "Compatibility" }).addClass("wizardHidden"); new Setting(containerSyncSettingEl) .setName("Always resolve conflict manually") @@ -1644,7 +1653,7 @@ ${stringifyYaml(pluginConfig)}`; if ((await this.plugin.localDatabase.putRaw(doc)).ok) { Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE); } - await this.plugin.showIfConflicted(docName as FilePathWithPrefix); + await this.plugin.queueConflictCheck(docName as FilePathWithPrefix); } else { Logger(`Converting ${docName} Failed!`, LOG_LEVEL_NOTICE); Logger(ret, LOG_LEVEL_VERBOSE); diff --git a/src/SerializedFileAccess.ts b/src/SerializedFileAccess.ts index a353a5d..bea1617 100644 --- a/src/SerializedFileAccess.ts +++ b/src/SerializedFileAccess.ts @@ -76,7 +76,7 @@ export class SerializedFileAccess { } else { return await serialized(getFileLockKey(file), async () => { const oldData = await this.app.vault.readBinary(file); - if (isDocContentSame(createBinaryBlob(oldData), createBinaryBlob(data))) { + if (await isDocContentSame(createBinaryBlob(oldData), createBinaryBlob(data))) { return false; } await this.app.vault.modifyBinary(file, toArrayBuffer(data), options) diff --git a/src/lib b/src/lib index 33f7e69..57e663b 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 33f7e69433c45692b73d97291a30186389fa12d2 +Subproject commit 57e663b6dc95ce5e3d3e7eecde620724a042d865 diff --git a/src/main.ts b/src/main.ts index e6586c5..f5ec082 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ const isDebug = false; import { type Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "./deps"; import { debounce, Notice, Plugin, TFile, addIcon, TFolder, normalizePath, TAbstractFile, Editor, MarkdownView, type RequestUrlParam, type RequestUrlResponse, requestUrl, type MarkdownFileInfo } from "./deps"; -import { type EntryDoc, type LoadedEntry, type ObsidianLiveSyncSettings, type diff_check_result, type diff_result_leaf, type EntryBody, LOG_LEVEL, VER, DEFAULT_SETTINGS, type diff_result, FLAGMD_REDFLAG, SYNCINFO_ID, SALT_OF_PASSPHRASE, type ConfigPassphraseStore, type CouchDBConnection, FLAGMD_REDFLAG2, FLAGMD_REDFLAG3, PREFIXMD_LOGFILE, type DatabaseConnectingStatus, type EntryHasPath, type DocumentID, type FilePathWithPrefix, type FilePath, type AnyEntry, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_URGENT, LOG_LEVEL_VERBOSE, type SavingEntry, } from "./lib/src/types"; +import { type EntryDoc, type LoadedEntry, type ObsidianLiveSyncSettings, type diff_check_result, type diff_result_leaf, type EntryBody, LOG_LEVEL, VER, DEFAULT_SETTINGS, type diff_result, FLAGMD_REDFLAG, SYNCINFO_ID, SALT_OF_PASSPHRASE, type ConfigPassphraseStore, type CouchDBConnection, FLAGMD_REDFLAG2, FLAGMD_REDFLAG3, PREFIXMD_LOGFILE, type DatabaseConnectingStatus, type EntryHasPath, type DocumentID, type FilePathWithPrefix, type FilePath, type AnyEntry, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_URGENT, LOG_LEVEL_VERBOSE, type SavingEntry, MISSING_OR_ERROR, NOT_CONFLICTED, AUTO_MERGED, CANCELLED, LEAVE_TO_SUBSEQUENT, } from "./lib/src/types"; import { type InternalFileInfo, type CacheData, type FileEventItem, FileWatchEventQueueMax } from "./types"; import { createBinaryBlob, createTextBlob, fireAndForget, getDocData, isDocContentSame, sendValue } from "./lib/src/utils"; import { Logger, setGlobalLogFunction } from "./lib/src/logger"; @@ -13,11 +13,11 @@ import { DocumentHistoryModal } from "./DocumentHistoryModal"; import { applyPatch, cancelAllPeriodicTask, cancelAllTasks, cancelTask, generatePatchObj, id2path, isObjectMargeApplicable, isSensibleMargeApplicable, flattenObject, path2id, scheduleTask, tryParseJSON, isValidPath, isInternalMetadata, isPluginMetadata, stripInternalMetadataPrefix, isChunk, askSelectString, askYesNo, askString, PeriodicProcessor, getPath, getPathWithoutPrefix, getPathFromTFile, performRebuildDB, memoIfNotExist, memoObject, retrieveMemoObject, disposeMemoObject, isCustomisationSyncMetadata } from "./utils"; import { encrypt, tryDecrypt } from "./lib/src/e2ee_v2"; import { balanceChunkPurgedDBs, enableEncryption, isCloudantURI, isErrorOfMissingDoc, isValidRemoteCouchDBURI, purgeUnreferencedChunks } from "./lib/src/utils_couchdb"; -import { lockStats, logStore, type LogEntry, collectingChunks, pluginScanningCount, hiddenFilesProcessingCount, hiddenFilesEventCount, logMessages } from "./lib/src/stores"; +import { logStore, type LogEntry, collectingChunks, pluginScanningCount, hiddenFilesProcessingCount, hiddenFilesEventCount, logMessages } from "./lib/src/stores"; import { setNoticeClass } from "./lib/src/wrapper"; import { versionNumberString2Number, writeString, decodeBinary, readString } from "./lib/src/strbin"; import { addPrefix, isAcceptedAll, isPlainText, shouldBeIgnored, stripAllPrefixes } from "./lib/src/path"; -import { isLockAcquired, serialized, skipIfDuplicated } from "./lib/src/lock"; +import { isLockAcquired, serialized, shareRunningResult, skipIfDuplicated } from "./lib/src/lock"; import { StorageEventManager, StorageEventManagerObsidian } from "./StorageEventManager"; import { LiveSyncLocalDB, type LiveSyncLocalDBEnv } from "./lib/src/LiveSyncLocalDB"; import { LiveSyncDBReplicator, type LiveSyncReplicatorEnv } from "./lib/src/LiveSyncReplicator"; @@ -365,7 +365,8 @@ export default class ObsidianLiveSyncPlugin extends Plugin const target = await askSelectString(this.app, "File to resolve conflict", notesList); if (target) { const targetItem = notes.find(e => e.dispPath == target); - await this.resolveConflicted(targetItem.path); + this.resolveConflicted(targetItem.path); + await this.conflictCheckQueue.waitForPipeline(); return true; } return false; @@ -373,13 +374,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin async resolveConflicted(target: FilePathWithPrefix) { if (isInternalMetadata(target)) { - await this.addOnHiddenFileSync.resolveConflictOnInternalFile(target); + this.addOnHiddenFileSync.queueConflictCheck(target); } else if (isPluginMetadata(target)) { await this.resolveConflictByNewerEntry(target); } else if (isCustomisationSyncMetadata(target)) { await this.resolveConflictByNewerEntry(target); } else { - await this.showIfConflicted(target); + this.queueConflictCheck(target); } } @@ -583,10 +584,10 @@ Note: We can always able to read V1 format. It will be progressively converted. this.addCommand({ id: "livesync-checkdoc-conflicted", name: "Resolve if conflicted.", - editorCallback: async (editor: Editor, view: MarkdownView | MarkdownFileInfo) => { + editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => { const file = view.file; if (!file) return; - await this.showIfConflicted(getPathFromTFile(file)); + this.queueConflictCheck(file); }, }); @@ -623,9 +624,10 @@ Note: We can always able to read V1 format. It will be progressively converted. this.addCommand({ id: "livesync-history", name: "Show history", - editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => { - if (view.file) this.showHistory(view.file, null); - }, + callback: () => { + const file = this.app.workspace.getActiveFile(); + if (file) this.showHistory(file, null); + } }); this.addCommand({ id: "livesync-scan-files", @@ -769,6 +771,9 @@ Note: We can always able to read V1 format. It will be progressively converted. } onunload() { + cancelAllPeriodicTask(); + cancelAllTasks(); + this._unloaded = true; for (const addOn of this.addOns) { addOn.onunload(); } @@ -780,9 +785,6 @@ Note: We can always able to read V1 format. It will be progressively converted. this.replicator.closeReplication(); this.localDatabase.close(); } - cancelAllPeriodicTask(); - cancelAllTasks(); - this._unloaded = true; Logger("unloading plugin"); } @@ -1138,7 +1140,7 @@ Note: We can always able to read V1 format. It will be progressively converted. if (this.settings.syncOnFileOpen && !this.suspended) { await this.replicate(); } - await this.showIfConflicted(getPathFromTFile(file)); + this.queueConflictCheck(file); } async applyBatchChange() { @@ -1296,7 +1298,7 @@ Note: We can always able to read V1 format. It will be progressively converted. await this.pullFile(path, null, true); } else { Logger(`Delete: ${file.path}: Conflicted revision has been deleted, but there were more conflicts...`); - this.queueConflictedOnlyActiveFile(file); + this.queueConflictCheck(file); } } else { Logger(`Delete: ${file.path}: Conflict revision has been deleted and resolved`); @@ -1367,21 +1369,16 @@ Note: We can always able to read V1 format. It will be progressively converted. } } - - queuedEntries: EntryBody[] = []; - - queueConflictedOnlyActiveFile(file: TFile) { - if (!this.settings.checkConflictOnlyOnOpen) { - this.queueConflictedCheck(file); - return true; - } else { + queueConflictCheck(file: FilePathWithPrefix | TFile) { + const path = file instanceof TFile ? getPathFromTFile(file) : file; + if (this.settings.checkConflictOnlyOnOpen) { const af = this.app.workspace.getActiveFile(); - if (af && af.path == file.path) { - this.queueConflictedCheck(file); - return true; + if (af && af.path != path) { + Logger(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE); + return; } } - return false; + this.conflictCheckQueue.enqueue(path); } saveQueuedFiles() { @@ -1441,15 +1438,13 @@ Note: We can always able to read V1 format. It will be progressively converted. const file = targetFile; if (this.settings.writeDocumentsIfConflicted) { await this.doc2storage(doc, file); - this.queueConflictedOnlyActiveFile(file); + this.queueConflictCheck(file); } else { const d = await this.localDatabase.getDBEntryMeta(this.getPath(entry), { conflicts: true }, true); if (d && !d._conflicts) { await this.doc2storage(doc, file); } else { - if (!this.queueConflictedOnlyActiveFile(file)) { - Logger(`${this.getPath(entry)} is conflicted, write to the storage has been postponed.`, LOG_LEVEL_NOTICE); - } + this.queueConflictCheck(file); } } } else { @@ -1541,14 +1536,15 @@ Note: We can always able to read V1 format. It will be progressively converted. const chunkCount = collectingChunks.value; const pluginScanCount = pluginScanningCount.value; const hiddenFilesCount = hiddenFilesEventCount.value + hiddenFilesProcessingCount.value; + const conflictProcessCount = this.conflictProcessQueueCount.value; const labelReplication = replicationCount ? `📥 ${replicationCount} ` : ""; const labelDBCount = dbCount ? `📄 ${dbCount} ` : ""; const labelStorageCount = storageApplyingCount ? `💾 ${storageApplyingCount}` : ""; const labelChunkCount = chunkCount ? `🧩${chunkCount} ` : ""; const labelPluginScanCount = pluginScanCount ? `🔌${pluginScanCount} ` : ""; const labelHiddenFilesCount = hiddenFilesCount ? `⚙️${hiddenFilesCount} ` : ""; - - return `${labelReplication}${labelDBCount}${labelStorageCount}${labelChunkCount}${labelPluginScanCount}${labelHiddenFilesCount}`; + const labelConflictProcessCount = conflictProcessCount ? `🔩${conflictProcessCount} ` : ""; + return `${labelReplication}${labelDBCount}${labelStorageCount}${labelChunkCount}${labelPluginScanCount}${labelHiddenFilesCount}${labelConflictProcessCount}`; }) const replicationStatLabel = reactive(() => { @@ -1625,9 +1621,6 @@ Note: We can always able to read V1 format. It will be progressively converted. statusBarLabels.onChanged(applyToDisplay); } - refreshStatusText() { - return; - } applyStatusBarText(message: string, log: string) { const newMsg = message; const newLog = log; @@ -2147,12 +2140,12 @@ Or if you are sure know what had been happened, we can unlock the database from * @param path the file location * @returns true -> resolved, false -> nothing to do, or check result. */ - async getConflictedStatus(path: FilePathWithPrefix): Promise { + async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise { const test = await this.localDatabase.getDBEntry(path, { conflicts: true, revs_info: true }, false, false, true); - if (test === false) return false; - if (test == null) return false; - if (!test._conflicts) return false; - if (test._conflicts.length == 0) return false; + if (test === false) return MISSING_OR_ERROR; + if (test == null) return MISSING_OR_ERROR; + if (!test._conflicts) return NOT_CONFLICTED; + if (test._conflicts.length == 0) return NOT_CONFLICTED; const conflicts = test._conflicts.sort((a, b) => Number(a.split("-")[0]) - Number(b.split("-")[0])); if ((isSensibleMargeApplicable(path) || isObjectMargeApplicable(path)) && !this.settings.disableMarkdownAutoMerge) { const conflictedRev = conflicts[0]; @@ -2198,7 +2191,7 @@ Or if you are sure know what had been happened, we can unlock the database from // ? await this.pullFile(path); Logger(`Automatically merged (sensible) :${path}`, LOG_LEVEL_INFO); - return true; + return AUTO_MERGED; } } } @@ -2208,14 +2201,14 @@ Or if you are sure know what had been happened, we can unlock the database from if (leftLeaf == false) { // what's going on.. Logger(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE); - return false; + return MISSING_OR_ERROR; } if (rightLeaf == false) { // Conflicted item could not load, delete this. await this.localDatabase.deleteDBEntry(path, { rev: conflicts[0] }); await this.pullFile(path, null, true); Logger(`could not get old revisions, automatically used newer one:${path}`, LOG_LEVEL_NOTICE); - return true; + return AUTO_MERGED; } const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted; @@ -2231,7 +2224,7 @@ Or if you are sure know what had been happened, we can unlock the database from await this.localDatabase.deleteDBEntry(path, { rev: loser.rev }); await this.pullFile(path, null, true); Logger(`Automatically merged (${isSame ? "same," : ""}${isBinary ? "binary," : ""}${alwaysNewer ? "alwaysNewer" : ""}) :${path}`, LOG_LEVEL_NOTICE); - return true; + return AUTO_MERGED; } // make diff. const dmp = new diff_match_patch(); @@ -2245,107 +2238,111 @@ Or if you are sure know what had been happened, we can unlock the database from }; } - showMergeDialog(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise { - return serialized("resolve-conflict:" + filename, () => - new Promise((res, rej) => { - Logger("open conflict dialog", LOG_LEVEL_VERBOSE); - new ConflictResolveModal(this.app, filename, conflictCheckResult, async (selected) => { - const testDoc = await this.localDatabase.getDBEntry(filename, { conflicts: true }, false, false, true); - if (testDoc === false) { - Logger("Missing file..", LOG_LEVEL_VERBOSE); - return res(true); - } - if (!testDoc._conflicts) { - Logger("Nothing have to do with this conflict", LOG_LEVEL_VERBOSE); - return res(true); - } - const toDelete = selected; - const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev; - if (toDelete == "") { - // concat both, - // delete conflicted revision and write a new file, store it again. - const p = conflictCheckResult.diff.map((e) => e[1]).join(""); - await this.localDatabase.deleteDBEntry(filename, { rev: testDoc._conflicts[0] }); - const file = this.vaultAccess.getAbstractFileByPath(stripAllPrefixes(filename)) as TFile; - if (file) { - if (await this.vaultAccess.vaultModify(file, p)) { - await this.updateIntoDB(file); - } - } else { - const newFile = await this.vaultAccess.vaultCreate(filename, p); - await this.updateIntoDB(newFile); - } - await this.pullFile(filename); - Logger("concat both file"); - if (this.settings.syncAfterMerge && !this.suspended) { - await this.replicate(); - } - setTimeout(() => { - //resolved, check again. - this.showIfConflicted(filename); - }, 50); - } else if (toDelete == null) { - Logger("Leave it still conflicted"); - } else { - await this.localDatabase.deleteDBEntry(filename, { rev: toDelete }); - await this.pullFile(filename, null, true, toKeep); - Logger(`Conflict resolved:${filename}`); - if (this.settings.syncAfterMerge && !this.suspended) { - await this.replicate(); - } - setTimeout(() => { - //resolved, check again. - this.showIfConflicted(filename); - }, 50); - } - - return res(true); - }).open(); - }) - ); - } - conflictedCheckFiles: FilePath[] = []; - - // queueing the conflicted file check - queueConflictedCheck(file: TFile) { - this.conflictedCheckFiles = this.conflictedCheckFiles.filter((e) => e != file.path); - this.conflictedCheckFiles.push(getPathFromTFile(file)); - scheduleTask("check-conflict", 100, async () => { - const checkFiles = JSON.parse(JSON.stringify(this.conflictedCheckFiles)) as FilePath[]; - for (const filename of checkFiles) { - try { - const file = this.vaultAccess.getAbstractFileByPath(filename); - if (file != null && file instanceof TFile) { - await this.showIfConflicted(getPathFromTFile(file)); - } - } catch (ex) { - Logger(ex); - } - } - }); - } - - async showIfConflicted(filename: FilePathWithPrefix) { - await serialized("conflicted", async () => { - const conflictCheckResult = await this.getConflictedStatus(filename); - if (conflictCheckResult === false) { - //nothing to do. + conflictProcessQueueCount = reactiveSource(0); + conflictResolveQueue = + new KeyedQueueProcessor(async (entries: { filename: FilePathWithPrefix, file: TFile }[]) => { + const entry = entries[0]; + const filename = entry.filename; + const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename); + if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === NOT_CONFLICTED || conflictCheckResult === CANCELLED) { + // nothing to do. return; } - if (conflictCheckResult === true) { + if (conflictCheckResult === AUTO_MERGED) { //auto resolved, but need check again; if (this.settings.syncAfterMerge && !this.suspended) { - await this.replicate(); + //Wait for the running replication, if not running replication, run it once. + await shareRunningResult(`replication`, () => this.replicate()); } Logger("conflict:Automatically merged, but we have to check it again"); - setTimeout(() => { - this.showIfConflicted(filename); - }, 50); + this.conflictCheckQueue.enqueue(filename); return; } - //there conflicts, and have to resolve ; - await this.showMergeDialog(filename, conflictCheckResult); + if (this.settings.showMergeDialogOnlyOnActive) { + const af = this.app.workspace.getActiveFile(); + if (af && af.path != filename) { + Logger(`${filename} is conflicted. Merging process has been postponed to the file have got opened.`, LOG_LEVEL_NOTICE); + return; + } + } + Logger("conflict:Manual merge required!"); + await this.resolveConflictByUI(filename, conflictCheckResult); + }, { suspended: false, batchSize: 1, concurrentLimit: 1, delay: 10, keepResultUntilDownstreamConnected: false }).replaceEnqueueProcessor( + (queue, newEntity) => { + const filename = newEntity.entity.filename; + sendValue("cancel-resolve-conflict:" + filename, true); + const newQueue = [...queue].filter(e => e.key != newEntity.key); + return [...newQueue, newEntity]; + }); + + + conflictCheckQueue = + // First process - Check is the file actually need resolve - + new QueueProcessor((files: FilePathWithPrefix[]) => { + const filename = files[0]; + const file = this.vaultAccess.getAbstractFileByPath(filename); + if (!file) return; + if (!(file instanceof TFile)) return; + // Check again? + + return [{ key: filename, entity: { filename, file } }]; + // this.conflictResolveQueue.enqueueWithKey(filename, { filename, file }); + }, { + suspended: false, batchSize: 1, concurrentLimit: 5, delay: 10, keepResultUntilDownstreamConnected: true, pipeTo: this.conflictResolveQueue, totalRemainingReactiveSource: this.conflictProcessQueueCount }); + + async resolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise { + Logger("Merge:open conflict dialog", LOG_LEVEL_VERBOSE); + const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult); + dialog.open(); + const selected = await dialog.waitForResult(); + if (selected === CANCELLED) { + // Cancelled by UI, or another conflict. + Logger(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO); + return; + } + const testDoc = await this.localDatabase.getDBEntry(filename, { conflicts: true }, false, false, true); + if (testDoc === false) { + Logger(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE); + return; + } + if (!testDoc._conflicts) { + Logger(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE); + return; + } + const toDelete = selected; + const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev; + if (toDelete === LEAVE_TO_SUBSEQUENT) { + // concat both, + // delete conflicted revision and write a new file, store it again. + const p = conflictCheckResult.diff.map((e) => e[1]).join(""); + await this.localDatabase.deleteDBEntry(filename, { rev: testDoc._conflicts[0] }); + const file = this.vaultAccess.getAbstractFileByPath(stripAllPrefixes(filename)) as TFile; + if (file) { + if (await this.vaultAccess.vaultModify(file, p)) { + await this.updateIntoDB(file); + } + } else { + const newFile = await this.vaultAccess.vaultCreate(filename, p); + await this.updateIntoDB(newFile); + } + await this.pullFile(filename); + Logger(`Merge: Changes has been concatenated: ${filename}`); + } else if (typeof toDelete === "string") { + await this.localDatabase.deleteDBEntry(filename, { rev: toDelete }); + await this.pullFile(filename, null, true, toKeep); + Logger(`Conflict resolved:${filename}`); + } else { + Logger(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE); + return; + } + // In here, some merge has been processed. + // So we have to run replication if configured. + if (this.settings.syncAfterMerge && !this.suspended) { + await shareRunningResult(`replication`, () => this.replicate()); + } + // And, check it again. + this.conflictCheckQueue.enqueue(filename); } async pullFile(filename: FilePathWithPrefix, fileList?: TFile[], force?: boolean, rev?: string, waitForReady = true) { diff --git a/src/utils.ts b/src/utils.ts index 5fb7708..310faf7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,12 +1,13 @@ -import { normalizePath, Platform, TAbstractFile, App, Plugin, type RequestUrlParam, requestUrl } from "./deps"; +import { normalizePath, Platform, TAbstractFile, App, type RequestUrlParam, requestUrl } from "./deps"; import { path2id_base, id2path_base, isValidFilenameInLinux, isValidFilenameInDarwin, isValidFilenameInWidows, isValidFilenameInAndroid, stripAllPrefixes } from "./lib/src/path"; import { Logger } from "./lib/src/logger"; import { LOG_LEVEL_VERBOSE, type AnyEntry, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix } from "./lib/src/types"; import { CHeader, ICHeader, ICHeaderLength, ICXHeader, PSCHeader } from "./types"; import { InputStringDialog, PopoverSelectString } from "./dialogs"; -import ObsidianLiveSyncPlugin from "./main"; +import type ObsidianLiveSyncPlugin from "./main"; import { writeString } from "./lib/src/strbin"; +import { fireAndForget } from "./lib/src/utils"; export { scheduleTask, setPeriodicTask, cancelTask, cancelAllTasks, cancelPeriodicTask, cancelAllPeriodicTask, } from "./lib/src/task"; @@ -336,8 +337,8 @@ export const askString = (app: App, title: string, key: string, placeholder: str export class PeriodicProcessor { _process: () => Promise; _timer?: number; - _plugin: Plugin; - constructor(plugin: Plugin, process: () => Promise) { + _plugin: ObsidianLiveSyncPlugin; + constructor(plugin: ObsidianLiveSyncPlugin, process: () => Promise) { this._plugin = plugin; this._process = process; } @@ -351,12 +352,19 @@ export class PeriodicProcessor { enable(interval: number) { this.disable(); if (interval == 0) return; - this._timer = window.setInterval(() => this.process().then(() => { }), interval); + this._timer = window.setInterval(() => fireAndForget(async () => { + await this.process(); + if (this._plugin._unloaded) { + this.disable(); + } + }), interval); this._plugin.registerInterval(this._timer); } disable() { - if (this._timer !== undefined) window.clearInterval(this._timer); - this._timer = undefined; + if (this._timer !== undefined) { + window.clearInterval(this._timer); + this._timer = undefined; + } } } diff --git a/styles.css b/styles.css index 96fe0b9..972904c 100644 --- a/styles.css +++ b/styles.css @@ -85,7 +85,6 @@ } */ - .sls-header-button { margin-left: 2em; } @@ -99,7 +98,7 @@ } .CodeMirror-wrap::before, -.cm-s-obsidian>.cm-editor::before, +.cm-s-obsidian > .cm-editor::before, .canvas-wrapper::before { content: attr(data-log); text-align: right; @@ -124,7 +123,7 @@ right: 0px; } -.cm-s-obsidian>.cm-editor::before { +.cm-s-obsidian > .cm-editor::before { right: 16px; } @@ -153,8 +152,8 @@ div.sls-setting-menu-btn { /* width: 100%; */ } -.sls-setting-tab:hover~div.sls-setting-menu-btn, -.sls-setting-label.selected .sls-setting-tab:checked~div.sls-setting-menu-btn { +.sls-setting-tab:hover ~ div.sls-setting-menu-btn, +.sls-setting-label.selected .sls-setting-tab:checked ~ div.sls-setting-menu-btn { background-color: var(--interactive-accent); color: var(--text-on-accent); } @@ -257,8 +256,8 @@ div.sls-setting-menu-btn { display: none; } -.password-input > .setting-item-control >input { - -webkit-text-security: disc; +.password-input > .setting-item-control > input { + -webkit-text-security: disc; } span.ls-mark-cr::after { @@ -270,4 +269,39 @@ span.ls-mark-cr::after { .deleted span.ls-mark-cr::after { color: var(--text-on-accent); -} \ No newline at end of file +} + +.ls-imgdiff-wrap { + display: flex; + justify-content: center; + align-items: center; +} + +.ls-imgdiff-wrap .overlay { + position: relative; +} + +.ls-imgdiff-wrap .overlay .img-base { + position: relative; + top: 0; + left: 0; +} +.ls-imgdiff-wrap .overlay .img-overlay { + -webkit-filter: invert(100%) opacity(50%); + filter: invert(100%) opacity(50%); + position: absolute; + top: 0; + left: 0; + animation: ls-blink-diff 0.5s cubic-bezier(0.4, 0, 1, 1) infinite alternate; +} +@keyframes ls-blink-diff { + 0% { + opacity: 0; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +}