From a27652ac34ca9d0a3e2817771859cfe78338e6d4 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 26 Sep 2025 11:40:41 +0100 Subject: [PATCH] ### Fixed - Chunk fetching no longer reports errors when the fetched chunk could not be saved (#710). - Just using the fetched chunk temporarily. - Chunk fetching reports errors when the fetched chunk is surely corrupted (#710, #712). - It no longer detects files that the plug-in has modified. - It may reduce unnecessary file comparisons and unexpected file states. ### Improved - Now checking the remote database configuration respecting the CouchDB version (#714). --- src/lib | 2 +- .../coreObsidian/ModuleFileAccessObsidian.ts | 34 +++++++- .../storageLib/SerializedFileAccess.ts | 84 +++++++++--------- .../storageLib/StorageEventManager.ts | 26 +++++- .../SettingDialogue/PaneRemoteConfig.ts | 86 +++++++++++++------ src/modules/interfaces/StorageAccess.ts | 4 + 6 files changed, 165 insertions(+), 71 deletions(-) diff --git a/src/lib b/src/lib index f021a9a..21ca077 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit f021a9a6f4e542b19d622a49ad3b8724efc978e5 +Subproject commit 21ca077163561cd34d17a98451ae2ee8030c2796 diff --git a/src/modules/coreObsidian/ModuleFileAccessObsidian.ts b/src/modules/coreObsidian/ModuleFileAccessObsidian.ts index 970fca7..3c1306d 100644 --- a/src/modules/coreObsidian/ModuleFileAccessObsidian.ts +++ b/src/modules/coreObsidian/ModuleFileAccessObsidian.ts @@ -15,10 +15,40 @@ import { TFileToUXFileInfoStub, TFolderToUXFileInfoStub } from "./storageLib/uti import { StorageEventManagerObsidian, type StorageEventManager } from "./storageLib/StorageEventManager"; import type { StorageAccess } from "../interfaces/StorageAccess"; import { createBlob, type CustomRegExp } from "../../lib/src/common/utils"; +import { serialized } from "octagonal-wheels/concurrency/lock_v2"; + +const fileLockPrefix = "file-lock:"; export class ModuleFileAccessObsidian extends AbstractObsidianModule implements IObsidianModule, StorageAccess { + processingFiles: Set = new Set(); + processWriteFile(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise): Promise { + const path = typeof file === "string" ? file : file.path; + return serialized(`${fileLockPrefix}${path}`, async () => { + try { + this.processingFiles.add(path); + return await proc(); + } finally { + this.processingFiles.delete(path); + } + }); + } + processReadFile(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise): Promise { + const path = typeof file === "string" ? file : file.path; + return serialized(`${fileLockPrefix}${path}`, async () => { + try { + this.processingFiles.add(path); + return await proc(); + } finally { + this.processingFiles.delete(path); + } + }); + } + isFileProcessing(file: UXFileInfoStub | FilePathWithPrefix): boolean { + const path = typeof file === "string" ? file : file.path; + return this.processingFiles.has(path); + } vaultAccess!: SerializedFileAccess; - vaultManager: StorageEventManager = new StorageEventManagerObsidian(this.plugin, this.core); + vaultManager: StorageEventManager = new StorageEventManagerObsidian(this.plugin, this.core, this); $everyOnload(): Promise { this.core.storageAccess = this; return Promise.resolve(true); @@ -42,7 +72,7 @@ export class ModuleFileAccessObsidian extends AbstractObsidianModule implements } $everyOnloadStart(): Promise { - this.vaultAccess = new SerializedFileAccess(this.app, this.plugin); + this.vaultAccess = new SerializedFileAccess(this.app, this.plugin, this); return Promise.resolve(true); } diff --git a/src/modules/coreObsidian/storageLib/SerializedFileAccess.ts b/src/modules/coreObsidian/storageLib/SerializedFileAccess.ts index 5e10e4a..e7ff18c 100644 --- a/src/modules/coreObsidian/storageLib/SerializedFileAccess.ts +++ b/src/modules/coreObsidian/storageLib/SerializedFileAccess.ts @@ -1,16 +1,11 @@ import { type App, TFile, type DataWriteOptions, TFolder, TAbstractFile } from "../../../deps.ts"; -import { serialized } from "octagonal-wheels/concurrency/lock"; import { Logger } from "../../../lib/src/common/logger.ts"; import { isPlainText } from "../../../lib/src/string_and_binary/path.ts"; import type { FilePath, HasSettings, UXFileInfoStub } from "../../../lib/src/common/types.ts"; import { createBinaryBlob, isDocContentSame } from "../../../lib/src/common/utils.ts"; import type { InternalFileInfo } from "../../../common/types.ts"; import { markChangesAreSame } from "../../../common/utils.ts"; -import { type UXFileInfo } from "../../../lib/src/common/types.ts"; - -function getFileLockKey(file: TFile | TFolder | string | UXFileInfo) { - return `fl:${typeof file == "string" ? file : file.path}`; -} +import type { StorageAccess } from "../../interfaces/StorageAccess.ts"; function toArrayBuffer(arr: Uint8Array | ArrayBuffer | DataView): ArrayBuffer { if (arr instanceof Uint8Array) { return arr.buffer; @@ -21,60 +16,55 @@ function toArrayBuffer(arr: Uint8Array | ArrayBuffer | DataView(file: TFile | TFolder | string | UXFileInfo, proc: () => Promise) { - const ret = await serialized(getFileLockKey(file), () => proc()); - return ret; -} -async function processWriteFile(file: TFile | TFolder | string | UXFileInfo, proc: () => Promise) { - const ret = await serialized(getFileLockKey(file), () => proc()); - return ret; -} - export class SerializedFileAccess { app: App; plugin: HasSettings<{ handleFilenameCaseSensitive: boolean }>; - constructor(app: App, plugin: (typeof this)["plugin"]) { + storageAccess: StorageAccess; + constructor(app: App, plugin: SerializedFileAccess["plugin"], storageAccess: StorageAccess) { this.app = app; this.plugin = plugin; + this.storageAccess = storageAccess; } async tryAdapterStat(file: TFile | string) { const path = file instanceof TFile ? file.path : file; - return await processReadFile(file, async () => { + return await this.storageAccess.processReadFile(path as FilePath, async () => { if (!(await this.app.vault.adapter.exists(path))) return null; return this.app.vault.adapter.stat(path); }); } async adapterStat(file: TFile | string) { const path = file instanceof TFile ? file.path : file; - return await processReadFile(file, () => this.app.vault.adapter.stat(path)); + return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.stat(path)); } async adapterExists(file: TFile | string) { const path = file instanceof TFile ? file.path : file; - return await processReadFile(file, () => this.app.vault.adapter.exists(path)); + return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.exists(path)); } async adapterRemove(file: TFile | string) { const path = file instanceof TFile ? file.path : file; - return await processReadFile(file, () => this.app.vault.adapter.remove(path)); + return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.remove(path)); } async adapterRead(file: TFile | string) { const path = file instanceof TFile ? file.path : file; - return await processReadFile(file, () => this.app.vault.adapter.read(path)); + return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.read(path)); } async adapterReadBinary(file: TFile | string) { const path = file instanceof TFile ? file.path : file; - return await processReadFile(file, () => this.app.vault.adapter.readBinary(path)); + return await this.storageAccess.processReadFile(path as FilePath, () => + this.app.vault.adapter.readBinary(path) + ); } async adapterReadAuto(file: TFile | string) { const path = file instanceof TFile ? file.path : file; - if (isPlainText(path)) return await processReadFile(file, () => this.app.vault.adapter.read(path)); - return await processReadFile(file, () => this.app.vault.adapter.readBinary(path)); + if (isPlainText(path)) { + return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.read(path)); + } + return await this.storageAccess.processReadFile(path as FilePath, () => + this.app.vault.adapter.readBinary(path) + ); } async adapterWrite( @@ -84,35 +74,39 @@ export class SerializedFileAccess { ) { const path = file instanceof TFile ? file.path : file; if (typeof data === "string") { - return await processWriteFile(file, () => this.app.vault.adapter.write(path, data, options)); + return await this.storageAccess.processWriteFile(path as FilePath, () => + this.app.vault.adapter.write(path, data, options) + ); } else { - return await processWriteFile(file, () => + return await this.storageAccess.processWriteFile(path as FilePath, () => this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options) ); } } async vaultCacheRead(file: TFile) { - return await processReadFile(file, () => this.app.vault.cachedRead(file)); + return await this.storageAccess.processReadFile(file.path as FilePath, () => this.app.vault.cachedRead(file)); } async vaultRead(file: TFile) { - return await processReadFile(file, () => this.app.vault.read(file)); + return await this.storageAccess.processReadFile(file.path as FilePath, () => this.app.vault.read(file)); } async vaultReadBinary(file: TFile) { - return await processReadFile(file, () => this.app.vault.readBinary(file)); + return await this.storageAccess.processReadFile(file.path as FilePath, () => this.app.vault.readBinary(file)); } async vaultReadAuto(file: TFile) { const path = file.path; - if (isPlainText(path)) return await processReadFile(file, () => this.app.vault.read(file)); - return await processReadFile(file, () => this.app.vault.readBinary(file)); + if (isPlainText(path)) { + return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.read(file)); + } + return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.readBinary(file)); } async vaultModify(file: TFile, data: string | ArrayBuffer | Uint8Array, options?: DataWriteOptions) { if (typeof data === "string") { - return await processWriteFile(file, async () => { + return await this.storageAccess.processWriteFile(file.path as FilePath, async () => { const oldData = await this.app.vault.read(file); if (data === oldData) { if (options && options.mtime) markChangesAreSame(file.path, file.stat.mtime, options.mtime); @@ -122,7 +116,7 @@ export class SerializedFileAccess { return true; }); } else { - return await processWriteFile(file, async () => { + return await this.storageAccess.processWriteFile(file.path as FilePath, async () => { const oldData = await this.app.vault.readBinary(file); if (await isDocContentSame(createBinaryBlob(oldData), createBinaryBlob(data))) { if (options && options.mtime) markChangesAreSame(file.path, file.stat.mtime, options.mtime); @@ -139,9 +133,13 @@ export class SerializedFileAccess { options?: DataWriteOptions ): Promise { if (typeof data === "string") { - return await processWriteFile(path, () => this.app.vault.create(path, data, options)); + return await this.storageAccess.processWriteFile(path as FilePath, () => + this.app.vault.create(path, data, options) + ); } else { - return await processWriteFile(path, () => this.app.vault.createBinary(path, toArrayBuffer(data), options)); + return await this.storageAccess.processWriteFile(path as FilePath, () => + this.app.vault.createBinary(path, toArrayBuffer(data), options) + ); } } @@ -154,10 +152,14 @@ export class SerializedFileAccess { } async delete(file: TFile | TFolder, force = false) { - return await processWriteFile(file, () => this.app.vault.delete(file, force)); + return await this.storageAccess.processWriteFile(file.path as FilePath, () => + this.app.vault.delete(file, force) + ); } async trash(file: TFile | TFolder, force = false) { - return await processWriteFile(file, () => this.app.vault.trash(file, force)); + return await this.storageAccess.processWriteFile(file.path as FilePath, () => + this.app.vault.trash(file, force) + ); } isStorageInsensitive(): boolean { diff --git a/src/modules/coreObsidian/storageLib/StorageEventManager.ts b/src/modules/coreObsidian/storageLib/StorageEventManager.ts index 3d28ea5..043468d 100644 --- a/src/modules/coreObsidian/storageLib/StorageEventManager.ts +++ b/src/modules/coreObsidian/storageLib/StorageEventManager.ts @@ -25,6 +25,7 @@ import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import type { LiveSyncCore } from "../../../main.ts"; import { InternalFileToUXFileInfoStub, TFileToUXFileInfoStub } from "./utilObsidian.ts"; import ObsidianLiveSyncPlugin from "../../../main.ts"; +import type { StorageAccess } from "../../interfaces/StorageAccess.ts"; // import { InternalFileToUXFileInfo } from "../platforms/obsidian.ts"; export type FileEvent = { @@ -46,6 +47,7 @@ export abstract class StorageEventManager { export class StorageEventManagerObsidian extends StorageEventManager { plugin: ObsidianLiveSyncPlugin; core: LiveSyncCore; + storageAccess: StorageAccess; get shouldBatchSave() { return this.core.settings?.batchSave && this.core.settings?.liveSync != true; @@ -56,8 +58,9 @@ export class StorageEventManagerObsidian extends StorageEventManager { get batchSaveMaximumDelay(): number { return this.core.settings?.batchSaveMaximumDelay ?? DEFAULT_SETTINGS.batchSaveMaximumDelay; } - constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore) { + constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, storageAccess: StorageAccess) { super(); + this.storageAccess = storageAccess; this.plugin = plugin; this.core = core; } @@ -88,6 +91,10 @@ export class StorageEventManagerObsidian extends StorageEventManager { } const file = info?.file as TFile; if (!file) return; + if (this.storageAccess.isFileProcessing(file.path as FilePath)) { + // Logger(`Editor change skipped because the file is being processed: ${file.path}`, LOG_LEVEL_VERBOSE); + return; + } if (!this.isWaiting(file.path as FilePath)) { return; } @@ -102,22 +109,35 @@ export class StorageEventManagerObsidian extends StorageEventManager { watchVaultCreate(file: TAbstractFile, ctx?: any) { if (file instanceof TFolder) return; + if (this.storageAccess.isFileProcessing(file.path as FilePath)) { + // Logger(`File create skipped because the file is being processed: ${file.path}`, LOG_LEVEL_VERBOSE); + return; + } const fileInfo = TFileToUXFileInfoStub(file); void this.appendQueue([{ type: "CREATE", file: fileInfo }], ctx); } watchVaultChange(file: TAbstractFile, ctx?: any) { if (file instanceof TFolder) return; + if (this.storageAccess.isFileProcessing(file.path as FilePath)) { + // Logger(`File change skipped because the file is being processed: ${file.path}`, LOG_LEVEL_VERBOSE); + return; + } const fileInfo = TFileToUXFileInfoStub(file); void this.appendQueue([{ type: "CHANGED", file: fileInfo }], ctx); } watchVaultDelete(file: TAbstractFile, ctx?: any) { if (file instanceof TFolder) return; + if (this.storageAccess.isFileProcessing(file.path as FilePath)) { + // Logger(`File delete skipped because the file is being processed: ${file.path}`, LOG_LEVEL_VERBOSE); + return; + } const fileInfo = TFileToUXFileInfoStub(file, true); void this.appendQueue([{ type: "DELETE", file: fileInfo }], ctx); } watchVaultRename(file: TAbstractFile, oldFile: string, ctx?: any) { + // vault Rename will not be raised for self-events (Self-hosted LiveSync will not handle 'rename'). if (file instanceof TFile) { const fileInfo = TFileToUXFileInfoStub(file); void this.appendQueue( @@ -145,6 +165,10 @@ export class StorageEventManagerObsidian extends StorageEventManager { } // Watch raw events (Internal API) watchVaultRawEvents(path: FilePath) { + if (this.storageAccess.isFileProcessing(path)) { + // Logger(`Raw file event skipped because the file is being processed: ${path}`, LOG_LEVEL_VERBOSE); + return; + } // Only for internal files. if (!this.plugin.settings) return; // if (this.plugin.settings.useIgnoreFiles && this.plugin.ignoreFiles.some(e => path.endsWith(e.trim()))) { diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts index 1332ad2..7686fb9 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts @@ -101,6 +101,23 @@ export function paneRemoteConfig( addResult($msg("obsidianLiveSyncSettingTab.msgIfConfigNotPersistent"), ["ob-btn-config-info"]); addResult($msg("obsidianLiveSyncSettingTab.msgConfigCheck"), ["ob-btn-config-head"]); + const serverBanner = r.headers["server"] ?? r.headers["Server"] ?? "unknown"; + addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner })); + const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/); + const versionStr = versionMatch ? versionMatch[2] : "0.0.0"; + const versionParts = `${versionStr}.0.0.0`.split("."); + // Compare version string with the target version. + // version must be a string like "3.2.1" or "3.10.2", and must be two or three parts. + function isGreaterThanOrEqual(version: string) { + const targetParts = version.split("."); + for (let i = 0; i < targetParts.length; i++) { + // compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a) + const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true }); + if (result > 0) return true; + if (result < 0) return false; + } + return true; + } // Admin check // for database creation and deletion if (!(this.editingSettings.couchDB_USER in responseConfig.admins)) { @@ -108,28 +125,31 @@ export function paneRemoteConfig( } else { addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges")); } - // HTTP user-authorization check - if (responseConfig?.chttpd?.require_valid_user != "true") { - isSuccessful = false; - addResult($msg("obsidianLiveSyncSettingTab.errRequireValidUser")); - addConfigFixButton( - $msg("obsidianLiveSyncSettingTab.msgSetRequireValidUser"), - "chttpd/require_valid_user", - "true" - ); + if (isGreaterThanOrEqual("3.2.0")) { + // HTTP user-authorization check + if (responseConfig?.chttpd?.require_valid_user != "true") { + isSuccessful = false; + addResult($msg("obsidianLiveSyncSettingTab.errRequireValidUser")); + addConfigFixButton( + $msg("obsidianLiveSyncSettingTab.msgSetRequireValidUser"), + "chttpd/require_valid_user", + "true" + ); + } else { + addResult($msg("obsidianLiveSyncSettingTab.okRequireValidUser")); + } } else { - addResult($msg("obsidianLiveSyncSettingTab.okRequireValidUser")); - } - if (responseConfig?.chttpd_auth?.require_valid_user != "true") { - isSuccessful = false; - addResult($msg("obsidianLiveSyncSettingTab.errRequireValidUserAuth")); - addConfigFixButton( - $msg("obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth"), - "chttpd_auth/require_valid_user", - "true" - ); - } else { - addResult($msg("obsidianLiveSyncSettingTab.okRequireValidUserAuth")); + if (responseConfig?.chttpd_auth?.require_valid_user != "true") { + isSuccessful = false; + addResult($msg("obsidianLiveSyncSettingTab.errRequireValidUserAuth")); + addConfigFixButton( + $msg("obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth"), + "chttpd_auth/require_valid_user", + "true" + ); + } else { + addResult($msg("obsidianLiveSyncSettingTab.okRequireValidUserAuth")); + } } // HTTPD check // Check Authentication header @@ -144,12 +164,26 @@ export function paneRemoteConfig( } else { addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth")); } - if (responseConfig?.httpd?.enable_cors != "true") { - isSuccessful = false; - addResult($msg("obsidianLiveSyncSettingTab.errEnableCors")); - addConfigFixButton($msg("obsidianLiveSyncSettingTab.msgEnableCors"), "httpd/enable_cors", "true"); + if (isGreaterThanOrEqual("3.2.0")) { + if (responseConfig?.chttpd?.enable_cors != "true") { + isSuccessful = false; + addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd")); + addConfigFixButton( + $msg("obsidianLiveSyncSettingTab.msgEnableCorsChttpd"), + "chttpd/enable_cors", + "true" + ); + } else { + addResult($msg("obsidianLiveSyncSettingTab.okEnableCorsChttpd")); + } } else { - addResult($msg("obsidianLiveSyncSettingTab.okEnableCors")); + if (responseConfig?.httpd?.enable_cors != "true") { + isSuccessful = false; + addResult($msg("obsidianLiveSyncSettingTab.errEnableCors")); + addConfigFixButton($msg("obsidianLiveSyncSettingTab.msgEnableCors"), "httpd/enable_cors", "true"); + } else { + addResult($msg("obsidianLiveSyncSettingTab.okEnableCors")); + } } // If the server is not cloudant, configure request size if (!isCloudantURI(this.editingSettings.couchDB_URI)) { diff --git a/src/modules/interfaces/StorageAccess.ts b/src/modules/interfaces/StorageAccess.ts index 955062d..c6a272f 100644 --- a/src/modules/interfaces/StorageAccess.ts +++ b/src/modules/interfaces/StorageAccess.ts @@ -10,6 +10,10 @@ import type { import type { CustomRegExp } from "../../lib/src/common/utils"; export interface StorageAccess { + processWriteFile(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise): Promise; + processReadFile(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise): Promise; + isFileProcessing(file: UXFileInfoStub | FilePathWithPrefix): boolean; + deleteVaultItem(file: FilePathWithPrefix | UXFileInfoStub | UXFolderInfo): Promise; writeFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise;