From 559c3f351bf6469f7eb1dc518334ab6083201f1e Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 26 Jun 2026 09:36:48 +0000 Subject: [PATCH] feat: add unit tests for replicateResultProcessor and refactor replicator logging - Introduced unit tests for the replicateResultProcessor, covering various scenarios including document enqueuing, snapshot handling, and processing of non-document changes. - Refactored replicator to utilize a logging function from the host API instead of a global logger, enhancing log management. - Updated mismatchedTweaksResolver to include logging through the host API, ensuring consistent logging practices across the application. - Adjusted tests to mock the new logging behavior and verify log outputs. --- .../modules/core/ModulePeriodicProcess.d.ts | 16 - _types/src/modules/core/ModuleReplicator.d.ts | 25 - .../modules/core/ModuleReplicatorCouchDB.d.ts | 11 - .../modules/core/ModuleReplicatorMinIO.d.ts | 10 - .../core/ReplicateResultProcessor.d.ts | 116 -- .../coreFeatures/ModuleConflictChecker.d.ts | 15 - .../coreFeatures/ModuleConflictResolver.d.ts | 19 - .../ModuleResolveMismatchedTweaks.d.ts | 36 - .../modules/essential/ModuleBasicMenu.d.ts | 8 - .../conflictResolution/conflictChecker.d.ts | 12 + .../conflictResolution/conflictResolver.d.ts | 16 + .../conflictResolution/index.d.ts | 4 + .../databaseMaintenance/types.d.ts | 2 +- .../devFeature/devOperations.d.ts | 7 + .../interactiveConflictResolver/index.d.ts | 2 +- .../interactiveConflictResolver/types.d.ts | 4 +- .../src/serviceFeatures/logFeature/index.d.ts | 2 +- .../src/serviceFeatures/logFeature/types.d.ts | 4 +- .../src/serviceFeatures/migration/index.d.ts | 3 +- .../migration/migrationOperations.d.ts | 12 + .../src/serviceFeatures/migration/types.d.ts | 6 + .../obsidianDocumentHistory/index.d.ts | 2 +- .../obsidianDocumentHistory/types.d.ts | 4 +- .../obsidianEvents/eventBindings.d.ts | 9 + .../serviceFeatures/obsidianEvents/index.d.ts | 2 +- .../serviceFeatures/obsidianEvents/types.d.ts | 4 +- .../serviceFeatures/obsidianMenu/index.d.ts | 2 +- .../obsidianSettingDialogue/index.d.ts | 2 +- .../obsidianSettingDialogue/types.d.ts | 4 +- .../periodicReplication/index.d.ts | 3 + .../periodicReplication.d.ts | 11 + .../serviceFeatures/replicator/commands.d.ts | 5 + .../src/serviceFeatures/replicator/index.d.ts | 4 + .../replicator/replicateResultProcessor.d.ts | 50 + .../replicator/replicator.d.ts | 16 + .../replicator/replicatorFactories.d.ts | 13 + .../serviceFeatures/tweakMismatch/index.d.ts | 3 + .../mismatchedTweaksResolver.d.ts | 32 + docs/adr/2026_06_refactoring_modules.md | 60 +- generate-types.mjs | 2 +- package-lock.json | 6 +- src/apps/cli/README.md | 1268 ++++++++--------- src/serviceFeatures/configSync/README.md | 9 +- .../configSync/eventBindings.ts | 3 +- .../conflictResolution/conflictChecker.ts | 11 +- .../conflictChecker.unit.spec.ts | 5 + .../conflictResolution/conflictResolver.ts | 54 +- .../conflictResolver.unit.spec.ts | 5 + .../databaseMaintenance/README.md | 6 +- src/serviceFeatures/hiddenFileSync/README.md | 15 +- .../hiddenFileSync/syncOperations.ts | 12 +- .../conflictOperations.ts | 2 +- .../interactiveConflictResolver/index.ts | 5 +- .../interactiveConflictResolver.unit.spec.ts | 4 +- .../interactiveConflictResolver/types.ts | 4 +- src/serviceFeatures/logFeature/index.ts | 13 +- .../logFeature/logOperations.ts | 4 +- src/serviceFeatures/logFeature/types.ts | 4 +- src/serviceFeatures/migration/index.ts | 337 +---- .../migration/migrationOperations.ts | 331 +++++ .../migrationOperations.unit.spec.ts | 241 ++++ src/serviceFeatures/migration/types.ts | 16 + src/serviceFeatures/mockServiceHub.ts | 23 + .../historyOperations.ts | 4 +- .../obsidianDocumentHistory/index.ts | 59 +- .../obsidianDocumentHistory.unit.spec.ts | 6 +- .../obsidianDocumentHistory/types.ts | 8 +- src/serviceFeatures/obsidianEvents/README.md | 5 +- .../obsidianEvents/appReload.ts | 2 +- .../obsidianEvents/eventBindings.ts | 75 + src/serviceFeatures/obsidianEvents/index.ts | 67 +- .../obsidianEvents.unit.spec.ts | 38 +- .../obsidianEvents/saveCommandHack.ts | 2 +- src/serviceFeatures/obsidianEvents/types.ts | 8 +- .../obsidianSettingDialogue/index.ts | 39 +- .../obsidianSettingDialogue.unit.spec.ts | 11 +- .../settingOperations.ts | 4 +- .../obsidianSettingDialogue/types.ts | 8 +- .../replicator/ReplicateResultProcessor.ts | 485 ------- .../ReplicateResultProcessor.unit.spec.ts | 27 - .../replicator/replicateResultProcessor.ts | 427 ++++++ .../replicateResultProcessor.unit.spec.ts | 224 +++ src/serviceFeatures/replicator/replicator.ts | 41 +- .../replicator/replicator.unit.spec.ts | 29 +- .../tweakMismatch/mismatchedTweaksResolver.ts | 49 +- .../mismatchedTweaksResolver.unit.spec.ts | 24 +- 86 files changed, 2569 insertions(+), 2009 deletions(-) delete mode 100644 _types/src/modules/core/ModulePeriodicProcess.d.ts delete mode 100644 _types/src/modules/core/ModuleReplicator.d.ts delete mode 100644 _types/src/modules/core/ModuleReplicatorCouchDB.d.ts delete mode 100644 _types/src/modules/core/ModuleReplicatorMinIO.d.ts delete mode 100644 _types/src/modules/core/ReplicateResultProcessor.d.ts delete mode 100644 _types/src/modules/coreFeatures/ModuleConflictChecker.d.ts delete mode 100644 _types/src/modules/coreFeatures/ModuleConflictResolver.d.ts delete mode 100644 _types/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.d.ts delete mode 100644 _types/src/modules/essential/ModuleBasicMenu.d.ts create mode 100644 _types/src/serviceFeatures/conflictResolution/conflictChecker.d.ts create mode 100644 _types/src/serviceFeatures/conflictResolution/conflictResolver.d.ts create mode 100644 _types/src/serviceFeatures/conflictResolution/index.d.ts create mode 100644 _types/src/serviceFeatures/migration/migrationOperations.d.ts create mode 100644 _types/src/serviceFeatures/migration/types.d.ts create mode 100644 _types/src/serviceFeatures/obsidianEvents/eventBindings.d.ts create mode 100644 _types/src/serviceFeatures/periodicReplication/index.d.ts create mode 100644 _types/src/serviceFeatures/periodicReplication/periodicReplication.d.ts create mode 100644 _types/src/serviceFeatures/replicator/commands.d.ts create mode 100644 _types/src/serviceFeatures/replicator/index.d.ts create mode 100644 _types/src/serviceFeatures/replicator/replicateResultProcessor.d.ts create mode 100644 _types/src/serviceFeatures/replicator/replicator.d.ts create mode 100644 _types/src/serviceFeatures/replicator/replicatorFactories.d.ts create mode 100644 _types/src/serviceFeatures/tweakMismatch/index.d.ts create mode 100644 _types/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.d.ts create mode 100644 src/serviceFeatures/migration/migrationOperations.ts create mode 100644 src/serviceFeatures/migration/migrationOperations.unit.spec.ts create mode 100644 src/serviceFeatures/migration/types.ts create mode 100644 src/serviceFeatures/obsidianEvents/eventBindings.ts delete mode 100644 src/serviceFeatures/replicator/ReplicateResultProcessor.ts delete mode 100644 src/serviceFeatures/replicator/ReplicateResultProcessor.unit.spec.ts create mode 100644 src/serviceFeatures/replicator/replicateResultProcessor.ts create mode 100644 src/serviceFeatures/replicator/replicateResultProcessor.unit.spec.ts diff --git a/_types/src/modules/core/ModulePeriodicProcess.d.ts b/_types/src/modules/core/ModulePeriodicProcess.d.ts deleted file mode 100644 index 5a6b20b..0000000 --- a/_types/src/modules/core/ModulePeriodicProcess.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { PeriodicProcessor } from "@/common/PeriodicProcessor"; -import type { LiveSyncCore } from "@/main"; -import { AbstractModule } from "@/modules/AbstractModule"; -export declare class ModulePeriodicProcess extends AbstractModule { - periodicSyncProcessor: PeriodicProcessor; - disablePeriodic(): Promise; - resumePeriodic(): Promise; - private _allOnUnload; - private _everyBeforeRealizeSetting; - private _everyBeforeSuspendProcess; - private _everyAfterResumeProcess; - private _everyAfterRealizeSetting; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/core/ModuleReplicator.d.ts b/_types/src/modules/core/ModuleReplicator.d.ts deleted file mode 100644 index 2d911d4..0000000 --- a/_types/src/modules/core/ModuleReplicator.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { AbstractModule } from "@/modules/AbstractModule"; -import { type EntryDoc, type RemoteType } from "@lib/common/types"; -import type { LiveSyncCore } from "@/main"; -import { ReplicateResultProcessor } from "./ReplicateResultProcessor"; -export declare class ModuleReplicator extends AbstractModule { - _replicatorType?: RemoteType; - processor: ReplicateResultProcessor; - private _unresolvedErrorManager; - clearErrors(): void; - private _everyOnloadAfterLoadSettings; - _onReplicatorInitialised(): Promise; - _everyOnDatabaseInitialized(showNotice: boolean): Promise; - _everyBeforeReplicate(showMessage: boolean): Promise; - /** - * obsolete method. No longer maintained and will be removed in the future. - * @deprecated v0.24.17 - * @param showMessage If true, show message to the user. - */ - cleaned(showMessage: boolean): Promise; - private onReplicationFailed; - _parseReplicationResult(docs: Array>): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/core/ModuleReplicatorCouchDB.d.ts b/_types/src/modules/core/ModuleReplicatorCouchDB.d.ts deleted file mode 100644 index 4b5ff02..0000000 --- a/_types/src/modules/core/ModuleReplicatorCouchDB.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type RemoteDBSettings } from "@lib/common/types"; -import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator"; -import { AbstractModule } from "@/modules/AbstractModule"; -import type { LiveSyncCore } from "@/main"; -export declare class ModuleReplicatorCouchDB extends AbstractModule { - _anyNewReplicator(settingOverride?: Partial): Promise; - _everyAfterResumeProcess(): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/core/ModuleReplicatorMinIO.d.ts b/_types/src/modules/core/ModuleReplicatorMinIO.d.ts deleted file mode 100644 index f2be06e..0000000 --- a/_types/src/modules/core/ModuleReplicatorMinIO.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type RemoteDBSettings } from "@lib/common/types"; -import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator"; -import type { LiveSyncCore } from "@/main"; -import { AbstractModule } from "@/modules/AbstractModule"; -export declare class ModuleReplicatorMinIO extends AbstractModule { - _anyNewReplicator(settingOverride?: Partial): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/modules/core/ReplicateResultProcessor.d.ts b/_types/src/modules/core/ReplicateResultProcessor.d.ts deleted file mode 100644 index e6d174b..0000000 --- a/_types/src/modules/core/ReplicateResultProcessor.d.ts +++ /dev/null @@ -1,116 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type AnyEntry, type EntryDoc, type LoadedEntry, type MetaEntry } from "@lib/common/types"; -import type { ModuleReplicator } from "./ModuleReplicator"; -import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -export declare class ReplicateResultProcessor { - private log; - private logError; - private replicator; - constructor(replicator: ModuleReplicator); - get localDatabase(): import("../../lib/src/pouchdb/LiveSyncLocalDB").LiveSyncLocalDB; - get services(): import("../../lib/src/services/InjectableServices").InjectableServiceHub; - get core(): LiveSyncBaseCore; - getPath(entry: AnyEntry): string; - suspend(): void; - resume(): void; - private _suspended; - get isSuspended(): boolean; - /** - * Take a snapshot of the current processing state. - * This snapshot is stored in the KV database for recovery on restart. - */ - protected _takeSnapshot(): Promise; - /** - * Trigger taking a snapshot. - */ - protected _triggerTakeSnapshot(): void; - /** - * Throttled version of triggerTakeSnapshot. - */ - protected triggerTakeSnapshot: import("octagonal-wheels/function").ThrottledFunction<() => void>; - /** - * Restore from snapshot. - */ - restoreFromSnapshot(): Promise; - private _restoreFromSnapshot; - /** - * Restore from snapshot only once. - * @returns Promise that resolves when restoration is complete. - */ - restoreFromSnapshotOnce(): Promise; - /** - * Perform the given procedure while counting the concurrency. - * @param proc async procedure to perform - * @param countValue reactive source to count concurrency - * @returns result of the procedure - */ - withCounting(proc: () => Promise, countValue: ReactiveSource): Promise; - /** - * Report the current status. - */ - protected reportStatus(): void; - /** - * Enqueue all the given changes for processing. - * @param changes Changes to enqueue - */ - enqueueAll(changes: PouchDB.Core.ExistingDocument[]): void; - /** - * Process the change if it is not a document change. - * @param change Change to process - * @returns True if the change was processed; false otherwise - */ - protected processIfNonDocumentChange(change: PouchDB.Core.ExistingDocument): boolean; - /** - * Queue of changes to be processed. - */ - private _queuedChanges; - /** - * List of changes being processed. - */ - private _processingChanges; - /** - * Enqueue the given document change for processing. - * @param doc Document change to enqueue - * @returns - */ - protected enqueueChange(doc: PouchDB.Core.ExistingDocument): void; - /** - * Trigger processing of the queued changes. - */ - protected triggerProcessQueue(): void; - /** - * Semaphore to limit concurrent processing. - * This is the per-id semaphore + concurrency-control (max 10 concurrent = 10 documents being processed at the same time). - */ - private _semaphore; - /** - * Flag indicating whether the process queue is currently running. - */ - private _isRunningProcessQueue; - /** - * Process the queued changes. - */ - private runProcessQueue; - /** - * Parse the given document change. - * @param change - * @returns - */ - parseDocumentChange(change: PouchDB.Core.ExistingDocument): Promise; - protected applyToDatabase(doc: PouchDB.Core.ExistingDocument): Promise; - private _applyToDatabase; - /** - * Phase 3: Apply the given entry to storage. - * @param entry - * @returns - */ - protected applyToStorage(entry: MetaEntry): Promise; - /** - * Check whether processing is required for the given document. - * @param dbDoc Document to check - * @returns True if processing is required; false otherwise - */ - protected checkIsChangeRequiredForDatabaseProcessing(dbDoc: LoadedEntry): Promise; -} diff --git a/_types/src/modules/coreFeatures/ModuleConflictChecker.d.ts b/_types/src/modules/coreFeatures/ModuleConflictChecker.d.ts deleted file mode 100644 index 4824060..0000000 --- a/_types/src/modules/coreFeatures/ModuleConflictChecker.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import { type FilePathWithPrefix } from "@lib/common/types"; -import { QueueProcessor } from "octagonal-wheels/concurrency/processor"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleConflictChecker extends AbstractModule { - _queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise; - _queueConflictCheck(file: FilePathWithPrefix): Promise; - _waitForAllConflictProcessed(): Promise; - conflictResolveQueue: QueueProcessor; - conflictCheckQueue: QueueProcessor; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} diff --git a/_types/src/modules/coreFeatures/ModuleConflictResolver.d.ts b/_types/src/modules/coreFeatures/ModuleConflictResolver.d.ts deleted file mode 100644 index b1c3302..0000000 --- a/_types/src/modules/coreFeatures/ModuleConflictResolver.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import { type diff_check_result, type FilePathWithPrefix } from "@lib/common/types"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -import type { LiveSyncCore } from "@/main.ts"; -declare global { - interface LSEvents { - "conflict-cancelled": FilePathWithPrefix; - } -} -export declare class ModuleConflictResolver extends AbstractModule { - private _resolveConflictByDeletingRev; - checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise; - private _resolveConflict; - private _anyResolveConflictByNewest; - private _resolveAllConflictedFilesByNewerOnes; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} diff --git a/_types/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.d.ts b/_types/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.d.ts deleted file mode 100644 index 7d37db6..0000000 --- a/_types/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type TweakValues, type ObsidianLiveSyncSettings, type RemoteDBSettings } from "@lib/common/types.ts"; -import { AbstractModule } from "@/modules/AbstractModule.ts"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts"; -import type { LiveSyncCore } from "@/main.ts"; -export declare class ModuleResolvingMismatchedTweaks extends AbstractModule { - private _hasNotifiedAutoAcceptCompatibleUndefined; - private _collectMismatchedTweakKeys; - private _selectNewerTweakSide; - private _shouldAutoAcceptCompatibleLossy; - /** - * Hook before saving settings, to check if there are changes in tweak values, and if so, - * update the tweakModified timestamp to current time. - * This allows other devices to know that the tweak values have been changed and decide whether to accept the new values based on the modification time. - * @param next - * @param previous - * @returns - */ - _onBeforeSaveSettingData(next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings): Promise<{ - tweakModified: number; - } | undefined>; - _anyAfterConnectCheckFailed(): Promise; - _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]>; - _askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE">; - _fetchRemotePreferredTweakValues(trialSetting: RemoteDBSettings): Promise; - _checkAndAskUseRemoteConfiguration(trialSetting: RemoteDBSettings): Promise<{ - result: false | TweakValues; - requireFetch: boolean; - }>; - _askUseRemoteConfiguration(trialSetting: RemoteDBSettings, preferred: TweakValues): Promise<{ - result: false | TweakValues; - requireFetch: boolean; - }>; - onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void; -} diff --git a/_types/src/modules/essential/ModuleBasicMenu.d.ts b/_types/src/modules/essential/ModuleBasicMenu.d.ts deleted file mode 100644 index db78a82..0000000 --- a/_types/src/modules/essential/ModuleBasicMenu.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-nocheck -// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import type { LiveSyncCore } from "@/main"; -import { AbstractModule } from "@/modules/AbstractModule"; -export declare class ModuleBasicMenu extends AbstractModule { - _everyOnloadStart(): Promise; - onBindFunction(core: LiveSyncCore, services: typeof core.services): void; -} diff --git a/_types/src/serviceFeatures/conflictResolution/conflictChecker.d.ts b/_types/src/serviceFeatures/conflictResolution/conflictChecker.d.ts new file mode 100644 index 0000000..ddc02bf --- /dev/null +++ b/_types/src/serviceFeatures/conflictResolution/conflictChecker.d.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import { type FilePathWithPrefix } from "@lib/common/types"; +import { QueueProcessor } from "octagonal-wheels/concurrency/processor"; +import type { NecessaryObsidianFeature } from "@/types"; +export type ConflictCheckerHost = NecessaryObsidianFeature<"API" | "conflict" | "vault" | "setting">; +export declare const queueConflictCheckIfOpenHandler: (host: ConflictCheckerHost, file: FilePathWithPrefix) => Promise; +export declare const queueConflictCheckHandler: (host: ConflictCheckerHost, queue: QueueProcessor, file: FilePathWithPrefix) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration +export declare function useConflictChecker(host: ConflictCheckerHost): { + conflictCheckQueue: QueueProcessor; + conflictResolveQueue: QueueProcessor; +}; diff --git a/_types/src/serviceFeatures/conflictResolution/conflictResolver.d.ts b/_types/src/serviceFeatures/conflictResolution/conflictResolver.d.ts new file mode 100644 index 0000000..16d690f --- /dev/null +++ b/_types/src/serviceFeatures/conflictResolution/conflictResolver.d.ts @@ -0,0 +1,16 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import { AUTO_MERGED, MISSING_OR_ERROR, type diff_check_result, type FilePathWithPrefix } from "@lib/common/types"; +import type { NecessaryObsidianFeature } from "@/types"; +declare global { + interface LSEvents { + "conflict-cancelled": FilePathWithPrefix; + } +} +export type ConflictResolverHost = NecessaryObsidianFeature<"API" | "conflict" | "appLifecycle" | "replication" | "vault" | "setting" | "database", "databaseFileAccess" | "fileHandler" | "storageAccess">; +export declare const resolveConflictByDeletingRevHandler: (host: ConflictResolverHost, path: FilePathWithPrefix, deleteRevision: string, subTitle?: string) => Promise; +export declare const checkConflictAndPerformAutoMerge: (host: ConflictResolverHost, path: FilePathWithPrefix) => Promise; +export declare const resolveConflictHandler: (host: ConflictResolverHost, filename: FilePathWithPrefix) => Promise; +export declare const resolveConflictByNewestHandler: (host: ConflictResolverHost, filename: FilePathWithPrefix) => Promise; +export declare const resolveAllConflictedFilesByNewerOnesHandler: (host: ConflictResolverHost) => Promise; +export declare function useConflictResolver(host: ConflictResolverHost): void; diff --git a/_types/src/serviceFeatures/conflictResolution/index.d.ts b/_types/src/serviceFeatures/conflictResolution/index.d.ts new file mode 100644 index 0000000..5330171 --- /dev/null +++ b/_types/src/serviceFeatures/conflictResolution/index.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +export { useConflictChecker } from "./conflictChecker"; +export { useConflictResolver } from "./conflictResolver"; diff --git a/_types/src/serviceFeatures/databaseMaintenance/types.d.ts b/_types/src/serviceFeatures/databaseMaintenance/types.d.ts index a2fd247..14a8cad 100644 --- a/_types/src/serviceFeatures/databaseMaintenance/types.d.ts +++ b/_types/src/serviceFeatures/databaseMaintenance/types.d.ts @@ -4,7 +4,7 @@ import type { NecessaryObsidianServices } from "@/types.ts"; /** * A union of service keys required by the database maintenance feature. */ -export type DatabaseMaintenanceServices = "API" | "setting" | "UI" | "database" | "keyValueDB" | "replication" | "replicator"; +export type DatabaseMaintenanceServices = "API" | "setting" | "UI" | "database" | "keyValueDB" | "replication" | "replicator" | "vault"; /** * A union of service module keys required by the database maintenance feature. */ diff --git a/_types/src/serviceFeatures/devFeature/devOperations.d.ts b/_types/src/serviceFeatures/devFeature/devOperations.d.ts index 3b04849..d3171e0 100644 --- a/_types/src/serviceFeatures/devFeature/devOperations.d.ts +++ b/_types/src/serviceFeatures/devFeature/devOperations.d.ts @@ -28,3 +28,10 @@ export declare function createConflict(host: DevFeatureHost): Promise; * @param message - Optional detailed stacktrace or assertion info. */ export declare function addTestResult(state: DevFeatureState, name: string, key: string, result: boolean, summary?: string, message?: string): void; +/** + * Dumps information of the specified document for debugging purposes. + * + * @param host - The service feature host context. + * @param file - The file path to dump. + */ +export declare function dumpDocument(host: DevFeatureHost, file: string | undefined): void; diff --git a/_types/src/serviceFeatures/interactiveConflictResolver/index.d.ts b/_types/src/serviceFeatures/interactiveConflictResolver/index.d.ts index 8e000fc..4f0762a 100644 --- a/_types/src/serviceFeatures/interactiveConflictResolver/index.d.ts +++ b/_types/src/serviceFeatures/interactiveConflictResolver/index.d.ts @@ -5,4 +5,4 @@ import type { ConflictResolverServices } from "./types.ts"; * A service feature hook that initialises and manages the Interactive Conflict Resolver. * Registers conflict resolution commands and handles user-interactive resolution flows. */ -export declare const useInteractiveConflictResolver: import("@lib/interfaces/ServiceModule.ts").ServiceFeatureFunction; +export declare const useInteractiveConflictResolver: import("@/types.ts").ObsidianServiceFeatureFunction; diff --git a/_types/src/serviceFeatures/interactiveConflictResolver/types.d.ts b/_types/src/serviceFeatures/interactiveConflictResolver/types.d.ts index acdd4f4..6053c46 100644 --- a/_types/src/serviceFeatures/interactiveConflictResolver/types.d.ts +++ b/_types/src/serviceFeatures/interactiveConflictResolver/types.d.ts @@ -1,6 +1,6 @@ // @ts-nocheck // REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * A union of service keys required by the interactive conflict resolver feature. */ @@ -12,4 +12,4 @@ export type ConflictResolverModules = "databaseFileAccess"; /** * The host type representing the injected service container with conflict resolution capabilities. */ -export type ConflictResolverHost = NecessaryServices; +export type ConflictResolverHost = NecessaryObsidianServices; diff --git a/_types/src/serviceFeatures/logFeature/index.d.ts b/_types/src/serviceFeatures/logFeature/index.d.ts index 9f1d43f..4263b2b 100644 --- a/_types/src/serviceFeatures/logFeature/index.d.ts +++ b/_types/src/serviceFeatures/logFeature/index.d.ts @@ -4,4 +4,4 @@ import type { LogFeatureServices } from "./types.ts"; /** * A service feature hook that initialises and manages logging, status display, and debug report generation. */ -export declare const useLogFeature: import("@lib/interfaces/ServiceModule.ts").ServiceFeatureFunction; +export declare const useLogFeature: import("@/types.ts").ObsidianServiceFeatureFunction; diff --git a/_types/src/serviceFeatures/logFeature/types.d.ts b/_types/src/serviceFeatures/logFeature/types.d.ts index 0e85876..2394dbe 100644 --- a/_types/src/serviceFeatures/logFeature/types.d.ts +++ b/_types/src/serviceFeatures/logFeature/types.d.ts @@ -1,6 +1,6 @@ // @ts-nocheck // REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * Service keys required by the logging and status bar feature. */ @@ -12,4 +12,4 @@ export type LogFeatureModules = "storageAccess"; /** * The host type representing the injected service container with logging capabilities. */ -export type LogFeatureHost = NecessaryServices; +export type LogFeatureHost = NecessaryObsidianServices; diff --git a/_types/src/serviceFeatures/migration/index.d.ts b/_types/src/serviceFeatures/migration/index.d.ts index 61b6be5..9fc555e 100644 --- a/_types/src/serviceFeatures/migration/index.d.ts +++ b/_types/src/serviceFeatures/migration/index.d.ts @@ -1,3 +1,4 @@ // @ts-nocheck // REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -export declare const useMigrationFeature: import("@/types.ts").ObsidianServiceFeatureFunction<"path" | "setting" | "UI" | "appLifecycle" | "API" | "database" | "replicator" | "vault" | "keyValueDB", "storageAccess" | "fileHandler" | "rebuilder", never, void>; +import type { MigrationModules, MigrationServices } from "./types.ts"; +export declare const useMigrationFeature: import("@/types.ts").ObsidianServiceFeatureFunction; diff --git a/_types/src/serviceFeatures/migration/migrationOperations.d.ts b/_types/src/serviceFeatures/migration/migrationOperations.d.ts new file mode 100644 index 0000000..92f5c28 --- /dev/null +++ b/_types/src/serviceFeatures/migration/migrationOperations.d.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import type { LogFunction } from "@lib/services/lib/logUtils.ts"; +import type { MigrationHost } from "./types.ts"; +export declare function migrateUsingDoctor(host: MigrationHost, skipRebuild?: boolean, activateReason?: string, forceRescan?: boolean): Promise; +export declare function migrateDisableBulkSend(host: MigrationHost, log: LogFunction): Promise; +export declare function initialMigrationMessage(): Promise; +export declare function askAgainForSetupURI(host: MigrationHost): Promise; +export declare function hasIncompleteDocs(host: MigrationHost, log: LogFunction, force?: boolean): Promise; +export declare function hasCompromisedChunks(host: MigrationHost, log: LogFunction): Promise; +export declare function runFirstInitialiseMigration(host: MigrationHost, log: LogFunction): Promise; +export declare function bindMigrationRequestEvents(host: MigrationHost, log: LogFunction): Promise; diff --git a/_types/src/serviceFeatures/migration/types.d.ts b/_types/src/serviceFeatures/migration/types.d.ts new file mode 100644 index 0000000..bce7629 --- /dev/null +++ b/_types/src/serviceFeatures/migration/types.d.ts @@ -0,0 +1,6 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import type { NecessaryObsidianServices } from "@/types.ts"; +export type MigrationServices = "API" | "appLifecycle" | "setting" | "database" | "path" | "vault" | "replicator" | "UI" | "keyValueDB"; +export type MigrationModules = "storageAccess" | "fileHandler" | "rebuilder"; +export type MigrationHost = NecessaryObsidianServices; diff --git a/_types/src/serviceFeatures/obsidianDocumentHistory/index.d.ts b/_types/src/serviceFeatures/obsidianDocumentHistory/index.d.ts index 64ff1f8..55edde3 100644 --- a/_types/src/serviceFeatures/obsidianDocumentHistory/index.d.ts +++ b/_types/src/serviceFeatures/obsidianDocumentHistory/index.d.ts @@ -5,4 +5,4 @@ import type { DocumentHistoryServices } from "./types.ts"; * A service feature hook that initialises and manages Obsidian Document History commands. * Registers ribbon commands and listens to history request events. */ -export declare const useObsidianDocumentHistory: import("@lib/interfaces/ServiceModule.ts").ServiceFeatureFunction; +export declare const useObsidianDocumentHistory: import("@/types.ts").ObsidianServiceFeatureFunction; diff --git a/_types/src/serviceFeatures/obsidianDocumentHistory/types.d.ts b/_types/src/serviceFeatures/obsidianDocumentHistory/types.d.ts index 8f1bf80..6cb6550 100644 --- a/_types/src/serviceFeatures/obsidianDocumentHistory/types.d.ts +++ b/_types/src/serviceFeatures/obsidianDocumentHistory/types.d.ts @@ -1,6 +1,6 @@ // @ts-nocheck // REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * Service keys required by the Obsidian document history feature. */ @@ -12,4 +12,4 @@ export type DocumentHistoryModules = never; /** * The host type representing the injected service container with document history capabilities. */ -export type DocumentHistoryHost = NecessaryServices; +export type DocumentHistoryHost = NecessaryObsidianServices; diff --git a/_types/src/serviceFeatures/obsidianEvents/eventBindings.d.ts b/_types/src/serviceFeatures/obsidianEvents/eventBindings.d.ts new file mode 100644 index 0000000..77b0cbd --- /dev/null +++ b/_types/src/serviceFeatures/obsidianEvents/eventBindings.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import type { LogFunction } from "@lib/services/lib/logUtils.ts"; +import type { ObsidianEventsState } from "./state.ts"; +import type { ObsidianEventsHost } from "./types.ts"; +export declare function registerVaultAndWorkspaceEvents(host: ObsidianEventsHost): Promise; +export declare function registerWindowWatchEvents(host: ObsidianEventsHost, log: LogFunction, state: ObsidianEventsState): void; +export declare function onObsidianEventsLayoutReady(host: ObsidianEventsHost, log: LogFunction, state: ObsidianEventsState): Promise; +export declare function bindObsidianEventsLifecycle(host: ObsidianEventsHost, log: LogFunction, state: ObsidianEventsState): void; diff --git a/_types/src/serviceFeatures/obsidianEvents/index.d.ts b/_types/src/serviceFeatures/obsidianEvents/index.d.ts index 79936cb..a4d62c6 100644 --- a/_types/src/serviceFeatures/obsidianEvents/index.d.ts +++ b/_types/src/serviceFeatures/obsidianEvents/index.d.ts @@ -5,4 +5,4 @@ import type { ObsidianEventsServices } from "./types.ts"; * A service feature hook that initialises and manages Obsidian application event bindings. * This hooks into vault file changes, window focus, visibility states, and schedules restarts. */ -export declare const useObsidianEvents: import("@lib/interfaces/ServiceModule").ServiceFeatureFunction; +export declare const useObsidianEvents: import("@/types.ts").ObsidianServiceFeatureFunction; diff --git a/_types/src/serviceFeatures/obsidianEvents/types.d.ts b/_types/src/serviceFeatures/obsidianEvents/types.d.ts index 08f197c..611aa27 100644 --- a/_types/src/serviceFeatures/obsidianEvents/types.d.ts +++ b/_types/src/serviceFeatures/obsidianEvents/types.d.ts @@ -1,6 +1,6 @@ // @ts-nocheck // REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * A union of service keys required by the Obsidian events management feature. */ @@ -12,4 +12,4 @@ export type ObsidianEventsModules = never; /** * The host type representing the injected service container with Obsidian events capabilities. */ -export type ObsidianEventsHost = NecessaryServices; +export type ObsidianEventsHost = NecessaryObsidianServices; diff --git a/_types/src/serviceFeatures/obsidianMenu/index.d.ts b/_types/src/serviceFeatures/obsidianMenu/index.d.ts index 1f63f95..fc99439 100644 --- a/_types/src/serviceFeatures/obsidianMenu/index.d.ts +++ b/_types/src/serviceFeatures/obsidianMenu/index.d.ts @@ -5,4 +5,4 @@ * * Provides Obsidian-specific UI elements like ribbon icons and commands. */ -export declare const useObsidianMenuFeature: import("@/types.ts").ObsidianServiceFeatureFunction<"replication" | "appLifecycle" | "conflict", never, "plugin", void>; +export declare const useObsidianMenuFeature: import("@/types.ts").ObsidianServiceFeatureFunction<"setting" | "replication" | "control" | "appLifecycle" | "API" | "fileProcessing" | "conflict", never, "plugin", void>; diff --git a/_types/src/serviceFeatures/obsidianSettingDialogue/index.d.ts b/_types/src/serviceFeatures/obsidianSettingDialogue/index.d.ts index 4e57f20..e3b8a27 100644 --- a/_types/src/serviceFeatures/obsidianSettingDialogue/index.d.ts +++ b/_types/src/serviceFeatures/obsidianSettingDialogue/index.d.ts @@ -4,4 +4,4 @@ import type { SettingDialogueServices } from "./types.ts"; /** * A service feature hook that registers the plug-in setting tab and listens to settings dialogue triggers. */ -export declare const useObsidianSettingDialogue: import("@lib/interfaces/ServiceModule.ts").ServiceFeatureFunction; +export declare const useObsidianSettingDialogue: import("@/types.ts").ObsidianServiceFeatureFunction; diff --git a/_types/src/serviceFeatures/obsidianSettingDialogue/types.d.ts b/_types/src/serviceFeatures/obsidianSettingDialogue/types.d.ts index 0a93353..9e991ff 100644 --- a/_types/src/serviceFeatures/obsidianSettingDialogue/types.d.ts +++ b/_types/src/serviceFeatures/obsidianSettingDialogue/types.d.ts @@ -1,6 +1,6 @@ // @ts-nocheck // REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * Service keys required by the Obsidian setting tab dialogue feature. */ @@ -12,4 +12,4 @@ export type SettingDialogueModules = never; /** * The host type representing the injected service container with setting tab capabilities. */ -export type SettingDialogueHost = NecessaryServices; +export type SettingDialogueHost = NecessaryObsidianServices; diff --git a/_types/src/serviceFeatures/periodicReplication/index.d.ts b/_types/src/serviceFeatures/periodicReplication/index.d.ts new file mode 100644 index 0000000..10a7518 --- /dev/null +++ b/_types/src/serviceFeatures/periodicReplication/index.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +export { usePeriodicReplication } from "./periodicReplication"; diff --git a/_types/src/serviceFeatures/periodicReplication/periodicReplication.d.ts b/_types/src/serviceFeatures/periodicReplication/periodicReplication.d.ts new file mode 100644 index 0000000..2d2a52b --- /dev/null +++ b/_types/src/serviceFeatures/periodicReplication/periodicReplication.d.ts @@ -0,0 +1,11 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import { PeriodicProcessor } from "@/common/PeriodicProcessor"; +import { type NecessaryObsidianFeature } from "@/types"; +export type PeriodicReplicationHost = NecessaryObsidianFeature<"appLifecycle" | "setting" | "replication" | "control" | "API">; +export declare const disablePeriodicHandler: (processor: PeriodicProcessor | undefined) => Promise; +export declare const resumePeriodicHandler: (host: PeriodicReplicationHost, processor: PeriodicProcessor) => Promise; +export declare function usePeriodicReplication(host: PeriodicReplicationHost): { + disablePeriodic: () => Promise; + resumePeriodic: () => Promise; +}; diff --git a/_types/src/serviceFeatures/replicator/commands.d.ts b/_types/src/serviceFeatures/replicator/commands.d.ts new file mode 100644 index 0000000..0edafde --- /dev/null +++ b/_types/src/serviceFeatures/replicator/commands.d.ts @@ -0,0 +1,5 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import type { NecessaryServices } from "@lib/interfaces/ServiceModule.ts"; +export type ReplicatorFeatureHost = NecessaryServices<"API" | "replication" | "replicator", never>; +export declare function registerReplicatorCommands(host: ReplicatorFeatureHost): void; diff --git a/_types/src/serviceFeatures/replicator/index.d.ts b/_types/src/serviceFeatures/replicator/index.d.ts new file mode 100644 index 0000000..9d0fb31 --- /dev/null +++ b/_types/src/serviceFeatures/replicator/index.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +export { useReplicator } from "./replicator"; +export { useCouchDBReplicatorFactory, useMinIOReplicatorFactory } from "./replicatorFactories"; diff --git a/_types/src/serviceFeatures/replicator/replicateResultProcessor.d.ts b/_types/src/serviceFeatures/replicator/replicateResultProcessor.d.ts new file mode 100644 index 0000000..08e4857 --- /dev/null +++ b/_types/src/serviceFeatures/replicator/replicateResultProcessor.d.ts @@ -0,0 +1,50 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import { type AnyEntry, type EntryDoc, type LoadedEntry, type MetaEntry } from "@lib/common/types"; +import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2"; +import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; +import type { NecessaryObsidianFeature } from "@/types"; +import { type LogFunction } from "@lib/services/lib/logUtils"; +export declare const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot"; +export type ReplicateResultProcessorHost = NecessaryObsidianFeature<"API" | "appLifecycle" | "database" | "keyValueDB" | "path" | "replication" | "replicator" | "setting" | "vault">; +export type ReplicateResultProcessorSnapshot = { + queued: PouchDB.Core.ExistingDocument[]; + processing: PouchDB.Core.ExistingDocument[]; +}; +export type ReplicateResultProcessorState = { + queuedChanges: PouchDB.Core.ExistingDocument[]; + processingChanges: PouchDB.Core.ExistingDocument[]; + suspended: boolean; + restoreFromSnapshot: Promise | undefined; + semaphore: ReturnType; + isRunningProcessQueue: boolean; + triggerTakeSnapshot: () => void; +}; +export type ReplicateResultProcessor = { + suspend: () => void; + resume: () => void; + enqueueAll: (changes: PouchDB.Core.ExistingDocument[]) => void; + restoreFromSnapshotOnce: () => Promise; +}; +type ReplicateResultProcessorLog = LogFunction; +export declare function createReplicateResultProcessorLog(host: ReplicateResultProcessorHost): ReplicateResultProcessorLog; +export declare function createReplicateResultProcessorState(triggerTakeSnapshot?: () => void): ReplicateResultProcessorState; +export declare function isReplicateResultProcessorSuspended(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState): boolean; +export declare function suspendReplicateResultProcessing(state: ReplicateResultProcessorState): void; +export declare function resumeReplicateResultProcessing(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): void; +export declare function takeReplicateResultProcessorSnapshot(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): Promise; +export declare function restoreReplicateResultProcessorSnapshot(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): Promise; +export declare function restoreReplicateResultProcessorSnapshotOnce(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): Promise; +export declare function withCounting(proc: () => Promise, countValue: ReactiveSource): Promise; +export declare function reportReplicateResultProcessorStatus(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState): void; +export declare function enqueueAllReplicateResults(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log: ReplicateResultProcessorLog, changes: PouchDB.Core.ExistingDocument[]): void; +export declare function processIfNonDocumentChange(host: ReplicateResultProcessorHost, log: ReplicateResultProcessorLog, change: PouchDB.Core.ExistingDocument): boolean; +export declare function enqueueReplicateResult(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log: ReplicateResultProcessorLog, doc: PouchDB.Core.ExistingDocument): void; +export declare function runReplicateResultProcessQueue(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log?: ReplicateResultProcessorLog): Promise; +export declare function parseReplicateResultDocumentChange(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log: ReplicateResultProcessorLog, change: PouchDB.Core.ExistingDocument): Promise; +export declare function applyReplicateResultToDatabase(host: ReplicateResultProcessorHost, state: ReplicateResultProcessorState, log: ReplicateResultProcessorLog, doc: PouchDB.Core.ExistingDocument): Promise; +export declare function applyReplicateResultToDatabaseInternal(host: ReplicateResultProcessorHost, log: ReplicateResultProcessorLog, doc_: PouchDB.Core.ExistingDocument): Promise; +export declare function applyReplicateResultToStorage(host: ReplicateResultProcessorHost, entry: MetaEntry): Promise; +export declare function checkIsChangeRequiredForDatabaseProcessing(host: ReplicateResultProcessorHost, log: ReplicateResultProcessorLog, dbDoc: LoadedEntry): Promise; +export declare function useReplicateResultProcessor(host: ReplicateResultProcessorHost): ReplicateResultProcessor; +export {}; diff --git a/_types/src/serviceFeatures/replicator/replicator.d.ts b/_types/src/serviceFeatures/replicator/replicator.d.ts new file mode 100644 index 0000000..d6c96c8 --- /dev/null +++ b/_types/src/serviceFeatures/replicator/replicator.d.ts @@ -0,0 +1,16 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import { type EntryDoc } from "@lib/common/types"; +import { type ReplicateResultProcessor } from "./replicateResultProcessor"; +import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; +import { type LogFunction } from "@lib/services/lib/logUtils"; +import type { NecessaryObsidianFeature } from "@/types"; +export type ReplicatorHost = NecessaryObsidianFeature<"appLifecycle" | "replication" | "replicator" | "setting" | "tweakValue" | "API" | "database" | "databaseEvents" | "keyValueDB" | "path" | "vault" | "UI", "databaseFileAccess" | "rebuilder">; +export declare const everyOnloadAfterLoadSettingsHandler: (host: ReplicatorHost, processor: ReplicateResultProcessor) => Promise; +export declare const onReplicatorInitialisedHandler: () => Promise; +export declare const everyOnDatabaseInitializedHandler: (processor: ReplicateResultProcessor, showNotice: boolean) => Promise; +export declare const everyBeforeReplicateHandler: (unresolvedErrorManager: UnresolvedErrorManager, processor: ReplicateResultProcessor, showMessage: boolean) => Promise; +export declare const cleanedHandler: (host: ReplicatorHost, showMessage: boolean, log?: LogFunction) => Promise; +export declare const onReplicationFailedHandler: (host: ReplicatorHost, showMessage?: boolean, log?: LogFunction) => Promise; +export declare const parseReplicationResultHandler: (processor: ReplicateResultProcessor, docs: Array>) => Promise; +export declare function useReplicator(host: ReplicatorHost): void; diff --git a/_types/src/serviceFeatures/replicator/replicatorFactories.d.ts b/_types/src/serviceFeatures/replicator/replicatorFactories.d.ts new file mode 100644 index 0000000..2778225 --- /dev/null +++ b/_types/src/serviceFeatures/replicator/replicatorFactories.d.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import { type RemoteDBSettings } from "@lib/common/types"; +import type { LiveSyncAbstractReplicator } from "@lib/replication/LiveSyncAbstractReplicator"; +import type { NecessaryObsidianFeature } from "@/types"; +type CouchDBReplicatorHost = NecessaryObsidianFeature<"replicator" | "appLifecycle" | "replication" | "setting">; +export declare const createCouchDBReplicatorHandler: (host: CouchDBReplicatorHost, settingOverride?: Partial) => Promise; +export declare const resumeCouchDBReplicationHandler: (host: CouchDBReplicatorHost) => Promise; +export declare function useCouchDBReplicatorFactory(host: CouchDBReplicatorHost): void; +type MinIOReplicatorHost = NecessaryObsidianFeature<"replicator" | "setting">; +export declare const createMinIOReplicatorHandler: (host: MinIOReplicatorHost, settingOverride?: Partial) => Promise; +export declare function useMinIOReplicatorFactory(host: MinIOReplicatorHost): void; +export {}; diff --git a/_types/src/serviceFeatures/tweakMismatch/index.d.ts b/_types/src/serviceFeatures/tweakMismatch/index.d.ts new file mode 100644 index 0000000..39cbc4d --- /dev/null +++ b/_types/src/serviceFeatures/tweakMismatch/index.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +export { useMismatchedTweaksResolver } from "./mismatchedTweaksResolver"; diff --git a/_types/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.d.ts b/_types/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.d.ts new file mode 100644 index 0000000..595cf26 --- /dev/null +++ b/_types/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.d.ts @@ -0,0 +1,32 @@ +// @ts-nocheck +// REPO: https://github.com/vrtmrz/livesync-commonlib Commit hash: 0563f26 +import { TweakValuesShouldMatchedTemplate, type TweakValues, type ObsidianLiveSyncSettings, type RemoteDBSettings } from "@lib/common/types.ts"; +import type { NecessaryObsidianFeature } from "@/types"; +import { type LogFunction } from "@lib/services/lib/logUtils"; +export type MismatchedTweaksResolverHost = NecessaryObsidianFeature<"API" | "setting" | "tweakValue" | "replication" | "replicator" | "UI", "rebuilder">; +export declare function valueToString(value: string | number | boolean | object | undefined): string; +export declare const collectMismatchedTweakKeys: (current: TweakValues, preferred: Partial) => ("liveSync" | "syncOnSave" | "syncOnStart" | "syncOnFileOpen" | "syncOnEditorSave" | "keepReplicationActiveInBackground" | "syncMinimumInterval" | "showVerboseLog" | "lessInformationInLog" | "showLongerLogInsideEditor" | "showStatusOnEditor" | "showStatusOnStatusbar" | "showOnlyIconsOnEditor" | "hideFileWarningNotice" | "networkWarningStyle" | "displayLanguage" | "trashInsteadDelete" | "doNotDeleteFolder" | "batchSave" | "batchSaveMinimumDelay" | "batchSaveMaximumDelay" | "syncMaxSizeInMB" | "useIgnoreFiles" | "ignoreFiles" | "processSizeMismatchedFiles" | "syncOnlyRegEx" | "syncIgnoreRegEx" | "syncAfterMerge" | "resolveConflictsByNewerFile" | "writeDocumentsIfConflicted" | "disableMarkdownAutoMerge" | "configPassphraseStore" | "encryptedPassphrase" | "encryptedCouchDBConnection" | "periodicReplication" | "periodicReplicationInterval" | "syncInternalFiles" | "syncInternalFilesBeforeReplication" | "syncInternalFilesInterval" | "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns" | "watchInternalFileChanges" | "suppressNotifyHiddenFilesChange" | "syncInternalFileOverwritePatterns" | "usePluginSync" | "usePluginSettings" | "showOwnPlugins" | "autoSweepPlugins" | "autoSweepPluginsPeriodic" | "notifyPluginOrSettingUpdated" | "deviceAndVaultName" | "usePluginSyncV2" | "usePluginEtc" | "pluginSyncExtendedSetting" | "useAdvancedMode" | "usePowerUserMode" | "useEdgeCaseMode" | "notifyThresholdOfRemoteStorageSize" | "disableWorkerForGeneratingChunks" | "processSmallFilesInUIThread" | "savingDelay" | "gcDelay" | "skipOlderFilesOnSync" | "useIndexedDBAdapter" | "enableDebugTools" | "writeLogToTheFile" | "settingSyncFile" | "writeCredentialsForSettingSync" | "notifyAllSettingSyncFile" | "suspendFileWatching" | "suspendParseReplicationResult" | "doNotSuspendOnFetching" | "maxMTimeForReflectEvents" | "versionUpFlash" | "settingVersion" | "isConfigured" | "lastReadUpdates" | "doctorProcessedVersion" | "remoteConfigurations" | "activeConfigurationId" | "P2P_ActiveRemoteConfigurationId" | "couchDB_URI" | "couchDB_USER" | "couchDB_PASSWORD" | "couchDB_DBNAME" | "couchDB_CustomHeaders" | "useJWT" | "jwtAlgorithm" | "jwtKey" | "jwtKid" | "jwtSub" | "jwtExpDuration" | "useRequestAPI" | "accessKey" | "secretKey" | "bucket" | "region" | "endpoint" | "useCustomRequestHandler" | "bucketCustomHeaders" | "bucketPrefix" | "forcePathStyle" | "remoteType" | "encrypt" | "passphrase" | "usePathObfuscation" | "E2EEAlgorithm" | "hashAlg" | "minimumChunkSize" | "customChunkSize" | "longLineThreshold" | "useSegmenter" | "enableChunkSplitterV2" | "doNotUseFixedRevisionForChunks" | "chunkSplitterVersion" | "useEden" | "maxChunksInEden" | "maxTotalLengthInEden" | "maxAgeInEden" | "tweakModified" | "checkIntegrityOnSave" | "useHistory" | "disableRequestURI" | "sendChunksBulk" | "sendChunksBulkMaxSize" | "useDynamicIterationCount" | "doNotPaceReplication" | "readChunksOnline" | "useOnlyLocalChunk" | "concurrencyOfReadChunksOnline" | "minimumIntervalOfReadChunksOnline" | "enableCompression" | "batch_size" | "batches_limit" | "ignoreVersionCheck" | "disableCheckingConfigMismatch" | "autoAcceptCompatibleTweak" | "hashCacheMaxCount" | "hashCacheMaxAmount" | "permitEmptyPassphrase" | "handleFilenameCaseSensitive" | "checkConflictOnlyOnOpen" | "showMergeDialogOnlyOnActive" | "additionalSuffixOfDatabaseName" | "useTimeouts" | "deleteMetadataOfDeletedFiles" | "automaticallyDeleteMetadataOfDeletedFiles" | "P2P_AutoAccepting" | "P2P_AutoSyncPeers" | "P2P_AutoWatchPeers" | "P2P_SyncOnReplication" | "P2P_RebuildFrom" | "P2P_AutoAcceptingPeers" | "P2P_AutoDenyingPeers" | "P2P_IsHeadless" | "P2P_Enabled" | "P2P_relays" | "P2P_roomID" | "P2P_passphrase" | "P2P_AppID" | "P2P_AutoStart" | "P2P_AutoBroadcast" | "P2P_DevicePeerName" | "P2P_turnServers" | "P2P_turnUsername" | "P2P_turnCredential" | "P2P_useDiagRTC")[]; +export declare const selectNewerTweakSide: (current: TweakValues, preferred: Partial, log?: LogFunction) => "REMOTE" | "CURRENT"; +export declare const shouldAutoAcceptCompatibleLossy: (host: MismatchedTweaksResolverHost, state: { + hasNotifiedAutoAcceptCompatibleUndefined: boolean; +}, current: TweakValues, preferred: Partial, mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]) => Promise<"REMOTE" | "CURRENT" | undefined>; +export declare const onBeforeSaveSettingDataHandler: (next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings, log?: LogFunction) => Promise<{ + tweakModified: number; +} | undefined>; +export declare const anyAfterConnectCheckFailedHandler: (host: MismatchedTweaksResolverHost) => Promise; +export declare const checkAndAskResolvingMismatchedTweaksHandler: (host: MismatchedTweaksResolverHost, state: { + hasNotifiedAutoAcceptCompatibleUndefined: boolean; +}, preferred: TweakValues) => Promise<[TweakValues | boolean, boolean]>; +export declare const askResolvingMismatchedTweaksHandler: (host: MismatchedTweaksResolverHost) => Promise<"OK" | "CHECKAGAIN" | "IGNORE">; +export declare const fetchRemotePreferredTweakValuesHandler: (host: MismatchedTweaksResolverHost, trialSetting: RemoteDBSettings) => Promise; +export declare const checkAndAskUseRemoteConfigurationHandler: (host: MismatchedTweaksResolverHost, trialSetting: RemoteDBSettings) => Promise<{ + result: false | TweakValues; + requireFetch: boolean; +}>; +export declare const askUseRemoteConfigurationHandler: (host: MismatchedTweaksResolverHost, state: { + hasNotifiedAutoAcceptCompatibleUndefined: boolean; +}, trialSetting: RemoteDBSettings, preferred: TweakValues) => Promise<{ + result: false | TweakValues; + requireFetch: boolean; +}>; +export declare function useMismatchedTweaksResolver(host: MismatchedTweaksResolverHost): void; diff --git a/docs/adr/2026_06_refactoring_modules.md b/docs/adr/2026_06_refactoring_modules.md index ec172eb..c9fbe04 100644 --- a/docs/adr/2026_06_refactoring_modules.md +++ b/docs/adr/2026_06_refactoring_modules.md @@ -22,36 +22,70 @@ We have decided to refactor these modules into **'serviceFeature'**s and **'Obsi 2. **'createObsidianServiceFeature'**: To support Obsidian-specific plug-in features that require direct access to the Obsidian application context (`app`, `plugin`, or `liveSyncPlugin`), we introduced the `createObsidianServiceFeature` helper and the `NecessaryObsidianFeature` utility type. This enables type-safe injection of the Obsidian context without casting to `any`. 3. **Core Types Relocation**: All service feature utility types (`LiveSyncCore`, `NecessaryObsidianFeature`, `ObsidianServiceFeatureFunction`, and `createObsidianServiceFeature`) were moved to [src/types.ts](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/types.ts) to prevent circular dependencies. +### State and Operation Boundaries + +In early refactoring, some features placed most logic directly inside the feature closure. This kept state local, but it made detailed unit testing awkward because important decisions were only reachable through registered handlers. + +The preferred pattern is now: + +- Keep runtime state inside the serviceFeature instance closure. +- Define the mutable state shape explicitly with a small `create...State()` factory. +- Extract business operations into dependency-explicit functions that accept `host`, `state`, and any small collaborators such as a log function. +- Keep the `use...Feature()` entrypoint thin: create state, create collaborators, bind handlers, and return only the local API needed by neighbouring features. +- Type the `host` with the exact services and service modules used by the feature, avoiding `as any` casts. + +This preserves the non-global nature of serviceFeatures while making queue merging, filtering, snapshot restoration, and error branches directly testable. These operation functions are not necessarily pure; database, storage, UI, and lifecycle effects remain explicit through injected services. + +### Closure Boundary Review + +The feature closure should contain state ownership and wiring, not hidden business logic. When a local function does more than bind an event, register a command, or adapt an Obsidian callback, prefer extracting it into an operation that receives `host`, `state`, and `log` explicitly. + +The current review classifies the refactored features as follows: + +- **Thin entrypoints**: `replicator`, `conflictResolution`, `tweakMismatch`, `globalHistory`, `obsidianDocumentHistory`, `obsidianSettingDialogue`, and `interactiveConflictResolver`. +- **Recently thinned**: `obsidianEvents` now delegates Obsidian event registration and lifecycle binding to `eventBindings.ts`, while keeping only log and state creation in `index.ts`. `migration` now delegates doctor checks, incomplete document checks, compromised chunk checks, and first-initialise sequencing to `migrationOperations.ts`. +- **Still worth follow-up extraction**: `logFeature`, `devFeature`, `obsidianSettingAsMarkdown`, and `setupManager` still contain sizeable lifecycle-local functions. They are acceptable as transitional refactors, but new work in these areas should move decision-making into operation files before adding behaviour. + +Direct global logging should also be avoided in serviceFeatures. Feature-local log functions should be created from `host.services.API` and passed into operations, matching the rest of the dependency-explicit pattern. + +### File Naming + +Files whose primary export is a class keep the class-oriented `CamelCase.ts` name. Files that contain only functions, or contain multiple cooperating exports rather than one primary class, use `snakeCase.ts`. For example, the replication result processor is implemented as functional operations and is therefore stored as `replicateResultProcessor.ts`, while its exported types may still use `ReplicateResultProcessor...` names. + ## Implementation Details ### Phase 1: Core Commands ('LiveSyncCommands' Inheritors) -These contain significant state and business logic. They have been refactored into pure functional modules under `src/serviceFeatures/`: +These contain significant state and business logic. They have been refactored into functional serviceFeature modules under `src/serviceFeatures/`: + - **[hiddenFileSync/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/hiddenFileSync/)**: Split monolithic file tracking and state variables into focused functional files. - **[configSync/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/configSync/)**: Decoupled periodic synchronisation, customisation scanning, and commands. -- **[databaseMaintenance/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/databaseMaintenance/)**: Refactored garbage collection, compaction, and diagnostics into pure modules. +- **[databaseMaintenance/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/databaseMaintenance/)**: Refactored garbage collection, compaction, and diagnostics into dependency-explicit operations. ### Phase 2: Obsidian UI & Events ('AbstractObsidianModule' Inheritors) These modules handle Obsidian-specific event bindings, UI registrations (views, dialogue modals, and ribbon commands), and user preferences. They have been refactored into 'ObsidianServiceFeature' functions: -- **[obsidianEvents/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianEvents/)**: Decoupled reload scheduling, save command overrides, and window visibility handlers. + +- **[obsidianEvents/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianEvents/)**: Decoupled reload scheduling, save command overrides, window visibility handlers, and Obsidian event lifecycle bindings. - **Stateless UI/Command Registrars**: - - `ModuleInteractiveConflictResolver` -> [interactiveConflictResolver/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/interactiveConflictResolver/) - - `ModuleObsidianDocumentHistory` -> [obsidianDocumentHistory/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianDocumentHistory/) - - `ModuleGlobalHistory` -> [globalHistory/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/globalHistory/) - - `ModuleLog` -> [logFeature/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/logFeature/) - - `ModuleObsidianSettingTab` -> [obsidianSettingDialogue/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianSettingDialogue/) - - `ModuleDev` -> [devFeature/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/devFeature/) + - `ModuleInteractiveConflictResolver` -> [interactiveConflictResolver/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/interactiveConflictResolver/) + - `ModuleObsidianDocumentHistory` -> [obsidianDocumentHistory/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianDocumentHistory/) + - `ModuleGlobalHistory` -> [globalHistory/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/globalHistory/) + - `ModuleLog` -> [logFeature/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/logFeature/) + - `ModuleObsidianSettingTab` -> [obsidianSettingDialogue/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianSettingDialogue/) + - `ModuleDev` -> [devFeature/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/devFeature/) - **Obsidian-Specific Tools**: - - `ModuleObsidianMenu` -> [obsidianMenu/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianMenu/) - - `ModuleObsidianSettingsAsMarkdown` -> [obsidianSettingAsMarkdown/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianSettingAsMarkdown/) - - `SetupManager` -> [setupManager/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/setupManager/) - - `ModuleMigration` -> [migration/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/migration/) + - `ModuleObsidianMenu` -> [obsidianMenu/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianMenu/) + - `ModuleObsidianSettingsAsMarkdown` -> [obsidianSettingAsMarkdown/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/obsidianSettingAsMarkdown/) + - `SetupManager` -> [setupManager/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/setupManager/) + - `ModuleMigration` -> [migration/](file:///p:/plant25/obsidian/projects/obsidian-livesync/src/serviceFeatures/migration/) with migration decisions extracted into dependency-explicit operations. ### Phase 3: Core Modules Evaluation Foundational modules (replicators and conflict resolver engines) will be evaluated in subsequent stages to decide if they should be true services on 'ServiceHub' or standalone features. +The replication result processor is classified as a standalone serviceFeature rather than a ServiceHub service. It owns local runtime state for queued and in-progress replication results, but it is only used by the replicator feature. Its processing logic should therefore be implemented as dependency-explicit operations over a typed host and local state, with the feature entrypoint wiring it into replication and database lifecycle handlers. + ## Consequences - **Encapsulated State**: Key state variables now live safely in feature closures rather than as global class properties. diff --git a/generate-types.mjs b/generate-types.mjs index 01ca67c..868e6fa 100644 --- a/generate-types.mjs +++ b/generate-types.mjs @@ -16,7 +16,7 @@ try { try { console.log("[Postbuild] Type definitions generated successfully. Adding ignore comments..."); - execSync("Deno run -A ./utilsdeno/types-add-ignore.ts", { stdio: "inherit" }); + execSync("deno run -A ./utilsdeno/types-add-ignore.ts", { stdio: "inherit" }); console.log("[Postbuild] Ignore comments added successfully."); } catch (error) { console.warn("[Postbuild] Failed to add ignore comments to type definitions."); diff --git a/package-lock.json b/package-lock.json index 9f3d5b4..d900986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16210,7 +16210,7 @@ }, "src/apps/cli": { "name": "self-hosted-livesync-cli", - "version": "0.25.77-cli", + "version": "0.25.78-cli", "dependencies": { "chokidar": "^4.0.0", "minimatch": "^10.2.5", @@ -16236,7 +16236,7 @@ }, "src/apps/webapp": { "name": "livesync-webapp", - "version": "0.25.77-webapp", + "version": "0.25.78-webapp", "dependencies": { "octagonal-wheels": "^0.1.46" }, @@ -16251,7 +16251,7 @@ } }, "src/apps/webpeer": { - "version": "0.25.77-webpeer", + "version": "0.25.78-webpeer", "dependencies": { "octagonal-wheels": "^0.1.46" }, diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 5579ae2..574a64a 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -1,634 +1,634 @@ -# Self-hosted LiveSync CLI -Command-line version of Self-hosted LiveSync plug-in for syncing vaults without Obsidian. - -## Features - -- ✅ Sync Obsidian vaults using CouchDB without running Obsidian -- ✅ Compatible with Self-hosted LiveSync plug-in settings -- ✅ Supports all core sync features (encryption, conflict resolution, etc.) -- ✅ Lightweight and headless operation -- ✅ Cross-platform (Windows, macOS, Linux) - -## Architecture - -This CLI version is built using the same core as the Obsidian plug-in: - -``` -CLI Main - └─ LiveSyncBaseCore - ├─ NodeServiceHub (All services without Obsidian dependencies) - └─ ServiceModules (wired by initialiseServiceModulesCLI) - ├─ FileAccessCLI (Node.js FileSystemAdapter) - ├─ StorageEventManagerCLI - ├─ ServiceFileAccessCLI - ├─ ServiceDatabaseFileAccessCLI - ├─ ServiceFileHandler - └─ ServiceRebuilder -``` - -### Key Components - -1. **Node.js FileSystem Adapter** (`adapters/`) - - Platform-agnostic file operations using Node.js `fs/promises` - - Implements same interface as Obsidian's file system - -2. **Service Modules** (`serviceModules/`) - - Initialised by `initialiseServiceModulesCLI` - - All core sync functionality preserved - -3. **Service Hub and Settings Services** (`services/`) - - `NodeServiceHub` provides the CLI service context - - Node-specific settings and key-value services are provided without Obsidian dependencies - -4. **Main Entry Point** (`main.ts`) - - Command-line interface - - Settings management (JSON file) - - Graceful shutdown handling - -## Usage - -The CLI operates on a **database directory** which contains PouchDB data and settings. - -> [!NOTE] -> `livesync-cli` is the alias for the CLI executable. Please replace with the actual command of your installation (e.g. `npm run --silent cli --` or `docker run ...`). - -```bash -livesync-cli [database-path] [command] [args...] -``` - - -### Arguments - -- `database-path`: Path to the directory where `.livesync` folder and `settings.json` are (or will be) located. - - Note: In previous versions, this was referred to as the "vault" path. Now it is clearly distinguished from the actual vault (the directory containing your `.md` files). -- `--vault ` / `-V `: (daemon/mirror only) Path to the vault directory containing `.md` files. - - Allows the PouchDB database directory and the actual vault directory to be different locations. - - For `mirror` command, the positional `[vault-path]` argument takes precedence over `--vault`. - -### Commands - -- `sync`: Run one replication cycle with the remote CouchDB. -- `mirror [vault-path]`: Bidirectional sync between the local database and a local directory (**the actual vault**). - - If `vault-path` is provided, the CLI will synchronise the database with files in the vault directory. - - If `vault-path` is omitted, it defaults to `database-path` (compatibility mode). - - Use this command to keep your local `.md` files in sync with the database. -- `ls [prefix]`: List files currently stored in the local database. -- `push `: Push a local file `` into the database at path ``. -- `pull `: Pull a file `` from the database into local file ``. -- `cat `: Read a file from the database and write to stdout. -- `put `: Read from stdin and write to the database path ``. -- `remote-add `: Add a remote configuration from a connection string. -- `remote-rm `: Remove a remote configuration by ID. -- `remote-ls`: List remote configurations (`id`, `name`, `active/inactive`, redacted URI). -- `remote-export `: Export the stored connection string by remote ID. -- `remote-set `: Replace the stored connection string by remote ID. -- `remote-activate `: Activate a remote configuration by ID. -- `mark-resolved [remote-id]`: Resolve remote synchronisation status. -- `unlock-remote [remote-id]`: Unlock the remote database. -- `lock-remote [remote-id]`: Lock the remote database. -- `remote-status [remote-id]`: Show remote database status. -- `init-settings [file]`: Create a default settings file. - -### Examples - -```bash -# Basic sync with remote -livesync-cli ./my-db sync - -# Mirroring to your actual Obsidian vault -livesync-cli ./my-db mirror /path/to/obsidian-vault - -# Manual file operations -livesync-cli ./my-db push ./note.md folder/note.md -livesync-cli ./my-db pull folder/note.md ./note.md -``` - -## Installation - -### Build from source - -```bash -# Clone with submodules, because the shared core lives in src/lib -git clone --recurse-submodules -cd obsidian-livesync - -# If you already cloned without submodules, run this once instead -git submodule update --init --recursive - -# Install dependencies from the repository root -npm install - -# Build the CLI from the repository root -npm run build -w self-hosted-livesync-cli - -# Or from the package directory -cd src/apps/cli -npm run build -``` - -If `src/lib` is missing, the build process stops early with a targeted message instead of a low-level Vite `ENOENT` error. - -Run the CLI: - -```bash -# Run with npm workspace script (from repository root) -npm run cli -w self-hosted-livesync-cli -- [database-path] [command] [args...] - -# Or from the package directory -cd src/apps/cli -npm run cli -- [database-path] [command] [args...] - -# Run the built executable directly -node src/apps/cli/dist/index.cjs [database-path] [command] [args...] -``` - -### Docker - -A Docker image is provided for headless / server deployments. Build from the repository root: - -```bash -docker build -f src/apps/cli/Dockerfile -t livesync-cli . -``` - -Run: - -```bash -# Sync with CouchDB -docker run --rm -v /path/to/your/db:/data livesync-cli sync - -# Mirror to a specific vault directory -docker run --rm -v /path/to/your/db:/data -v /path/to/your/vault:/vault livesync-cli mirror /vault - -# List files in the local database -docker run --rm -v /path/to/your/db:/data livesync-cli ls -``` - -The database directory is mounted at `/data` by default. Override with `-e LIVESYNC_DB_PATH=/other/path`. - -#### P2P (WebRTC) and Docker networking - -The P2P replicator (`p2p-host`, `p2p-sync`, `p2p-peers`) uses WebRTC and generates -three kinds of ICE candidates. The default Docker bridge network affects which -candidates are usable: - -| Candidate type | Description | Bridge network | -| -------------- | ---------------------------------- | -------------------------- | -| `host` | Container bridge IP (`172.17.x.x`) | Unreachable from LAN peers | -| `srflx` | Host public IP via STUN reflection | Works over the internet | -| `relay` | Traffic relayed via TURN server | Always reachable | - -**LAN P2P on Linux** — use `--network host` so that the real host IP is -advertised as the `host` candidate: - -```bash -docker run --rm --network host -v /path/to/your/vault:/data livesync-cli p2p-host -``` - -Note: also fix the alias to include `--network host` if you want to use `livesync-cli` for P2P commands. - -> `--network host` is not available on Docker Desktop for macOS or Windows. - -**LAN P2P on macOS / Windows Docker Desktop** — configure a TURN server in the -settings file (`P2P_turnServers`, `P2P_turnUsername`, `P2P_turnCredential`). -All P2P traffic will then be relayed through the TURN server, bypassing the -bridge-network limitation. - -**Internet P2P** — the default bridge network is sufficient. The `srflx` -candidate carries the host's public IP and peers can connect normally. - -**CouchDB sync only (no P2P)** — no special network configuration is required. - - -### Adding `livesync-cli` alias - -To use the `livesync-cli` command globally, you can add an alias to your shell configuration file (e.g., `.zshrc` or `.bashrc`). - -If you are using `npm run`, add the following line: - -```bash -alias livesync-cli='npm run --silent --prefix /path/to/repository/src/apps/cli cli --' -# or -alias livesync-cli="npm run --silent --prefix $PWD cli --" -``` - -Alternatively, if you want to use the built executable directly: - -```bash -alias livesync-cli='node /path/to/repository/src/apps/cli/dist/index.cjs' -or -alias livesync-cli="node $PWD/dist/index.cjs" -``` - -If you prefer using Docker: - -```bash -alias livesync-cli='docker run --rm -v /path/to/your/db:/data livesync-cli' -``` - -After adding the alias, restart your shell or run `source ~/.zshrc` (or `.bashrc`). - -## Usage - -### Basic Usage - -As you know, the CLI is designed to be used in a headless environment. Hence all operations are performed against a local vault directory and a settings file. Here are some example commands: - -```bash -# Sync local database with CouchDB (no files will be changed). -livesync-cli /path/to/your-local-database --settings /path/to/settings.json sync - -# Push files to local database -livesync-cli /path/to/your-local-database --settings /path/to/settings.json push /your/storage/file.md /vault/path/file.md - -# Pull files from local database -livesync-cli /path/to/your-local-database --settings /path/to/settings.json pull /vault/path/file.md /your/storage/file.md - -# Verbose logging -livesync-cli /path/to/your-local-database --settings /path/to/settings.json --verbose - -# Apply setup URI to settings file (settings only; does not run synchronisation) -livesync-cli /path/to/your-local-database --settings /path/to/settings.json setup "obsidian://setuplivesync?settings=..." - -# Put text from stdin into local database -echo "Hello from stdin" | livesync-cli /path/to/your-local-database --settings /path/to/settings.json put /vault/path/file.md - -# Output a file from local database to stdout -livesync-cli /path/to/your-local-database --settings /path/to/settings.json cat /vault/path/file.md - -# Output a specific revision of a file from local database -livesync-cli /path/to/your-local-database --settings /path/to/settings.json cat-rev /vault/path/file.md 3-abcdef - -# Pull a specific revision of a file from local database to local storage -livesync-cli /path/to/your-local-database --settings /path/to/settings.json pull-rev /vault/path/file.md /your/storage/file.old.md 3-abcdef - -# List files in local database -livesync-cli /path/to/your-local-database --settings /path/to/settings.json ls /vault/path/ - -# Show metadata for a file in local database -livesync-cli /path/to/your-local-database --settings /path/to/settings.json info /vault/path/file.md - -# Mark a file as deleted in local database -livesync-cli /path/to/your-local-database --settings /path/to/settings.json rm /vault/path/file.md - -# Resolve conflict by keeping a specific revision -livesync-cli /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef - -# Add, list, activate, and remove remote configurations -livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-add main "sls+https://user:pass@example.com/db" -livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-ls -livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-export remote-abc123 -livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-set remote-abc123 "sls+p2p://room-abc?passphrase=secret" -livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-activate remote-abc123 -livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-rm remote-abc123 - -# Lock, unlock, resolve, and view status of remote database -livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-status remote-abc123 -livesync-cli /path/to/your-local-database --settings /path/to/settings.json lock-remote remote-abc123 -livesync-cli /path/to/your-local-database --settings /path/to/settings.json mark-resolved remote-abc123 -livesync-cli /path/to/your-local-database --settings /path/to/settings.json unlock-remote remote-abc123 -``` - -### Configuration - -The CLI uses the same settings format as the Obsidian plug-in. Create a `.livesync/settings.json` file in your vault directory: - -```json -{ - "couchDB_URI": "http://localhost:5984", - "couchDB_USER": "admin", - "couchDB_PASSWORD": "password", - "couchDB_DBNAME": "obsidian-livesync", - "liveSync": true, - "syncOnSave": true, - "syncOnStart": true, - "encrypt": true, - "passphrase": "your-encryption-passphrase", - "usePluginSync": false, - "isConfigured": true -} -``` - -**Minimum required settings:** - -- `couchDB_URI`: CouchDB server URL -- `couchDB_USER`: CouchDB username -- `couchDB_PASSWORD`: CouchDB password -- `couchDB_DBNAME`: Database name -- `isConfigured`: Set to `true` after configuration - -### Command-line Reference - -``` -Usage: - livesync-cli [options] [command-args] - livesync-cli init-settings [path] - -Arguments: - database-path Path to the local database directory (required except for init-settings) - -Options: - --settings, -s Path to settings file (default: .livesync/settings.json in local database directory) - --force, -f Overwrite existing file on init-settings - --verbose, -v Enable verbose logging - --debug, -d Enable debug logging (includes verbose) - --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed - --vault , -V (daemon/mirror) Path to vault directory, decoupled from database-path - --help, -h Show this help message - -Commands: - daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem - init-settings [path] Create settings JSON from DEFAULT_SETTINGS - sync Run one replication cycle and exit - p2p-peers Show discovered peers as [peer] - p2p-sync Synchronise with specified peer-id or peer-name - p2p-host Start P2P host mode and wait until interrupted (Ctrl+C) - push Push local file into local database path - pull Pull file from local database into local file - pull-rev Pull specific revision into local file - setup Apply setup URI to settings file - put Read text from standard input and write to local database path - cat Write latest file content from local database to standard output - cat-rev Write specific revision content from local database to standard output - ls [prefix] List files as pathsizemtimerevision[*] - info Show file metadata including current and past revisions, conflicts, and chunk list - rm Mark file as deleted in local database - resolve Resolve conflict by keeping the specified revision - mirror [vaultPath] Mirror database contents to the local file system - (vaultPath positional arg > --vault flag > database-path) -``` - -Run via npm script: - -```bash -npm run --silent cli -- [database-path] [options] [command] [command-args] -``` - -#### Detailed Command Descriptions - -##### ls -`ls` lists files in the local database with optional prefix filtering. Output format is: - -```vault/path/file.mdsizemtimerevision[*] -``` -Note: `*` indicates if the file has conflicts. - -##### p2p-peers - -`p2p-peers ` waits for the specified number of seconds, then prints each discovered peer on a separate line: - -```text -[peer] -``` - -Use this command to select a target for `p2p-sync`. - -##### p2p-sync - -`p2p-sync ` discovers peers up to the specified timeout and synchronises with the selected peer. - -- `` accepts either `peer-id` or `peer-name` from `p2p-peers` output. -- On success, the command prints a completion message to standard error and exits with status code `0`. -- On failure, the command prints an error message and exits non-zero. - -##### p2p-host - -`p2p-host` starts the local P2P host and keeps running until interrupted. - -- Other peers can discover and synchronise with this host while it is running. -- Stop the host with `Ctrl+C`. -- In CLI mode, behaviour is non-interactive and acceptance follows settings. - -##### info - -`info` output fields: - -- `id`: Document ID -- `revision`: Current revision -- `conflicts`: Conflicted revisions, or `N/A` -- `filename`: Basename of path -- `path`: Vault-relative path -- `size`: Size in bytes -- `revisions`: Available non-current revisions -- `chunks`: Number of chunk IDs -- `children`: Chunk ID list - -##### mirror - -`mirror` is a command that synchronises your storage with your local vault. It is essentially a process that runs upon startup in Obsidian. - -In other words, it performs the following actions: - -1. **Precondition checks** — Aborts early if any of the following conditions are not met: - - Settings must be configured (`isConfigured: true`). - - File watching must not be suspended (`suspendFileWatching: false`). - - Remediation mode must be inactive (`maxMTimeForReflectEvents: 0`). - -2. **State restoration** — On subsequent runs (after the first successful scan), restores the previous storage state before proceeding. - -3. **Expired deletion cleanup** — If `automaticallyDeleteMetadataOfDeletedFiles` is set to a positive number of days, any document that is marked deleted and whose `mtime` is older than the retention period is permanently removed from the local database. - -4. **File collection** — Enumerates files from two sources: - - **Storage**: all files under the vault path that pass `isTargetFile`. - - **Local database**: all normal documents (fetched with conflict information) whose paths are valid and pass `isTargetFile`. - - Both collections build case-insensitive ↔ case-sensitive path maps, controlled by `handleFilenameCaseSensitive`. - -5. **Categorisation and synchronisation** — The union of both file sets is split into three groups and processed concurrently (up to 10 files at a time): - - | Group | Condition | Action | - | ----------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - | **UPDATE DATABASE** | File exists in storage only | Store the file into the local database. | - | **UPDATE STORAGE** | File exists in database only | If the entry is active (not deleted) and not conflicted, restore the file from the database to storage. Deleted entries and conflicted entries are skipped. | - | **SYNC DATABASE AND STORAGE** | File exists in both | Compare `mtime` freshness. If storage is newer → write to database (`STORAGE → DB`). If database is newer → restore to storage (`STORAGE ← DB`). If equal → do nothing. Conflicted documents and files exceeding the size limit are always skipped. | - -6. **Initialisation flag** — On the very first successful run, writes `initialized = true` to the key-value database so that subsequent runs can restore state in step 2. - -Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases). - -##### daemon - -`daemon` is the default command when no command is specified. It runs an initial mirror scan and then continuously syncs changes in both directions: - -- **CouchDB → local filesystem**: via the `_changes` feed (LiveSync mode, default) or periodic polling (`--interval N`). -- **local filesystem → CouchDB**: via chokidar file watching. Any file created, modified, or deleted in the vault directory is pushed to CouchDB. - -In **LiveSync mode** the `_changes` feed delivers remote changes as they arrive, with sub-second latency. In **polling mode** (`--interval N`) the CLI polls CouchDB every N seconds. Use polling mode if your CouchDB instance does not support long-lived HTTP connections, or if you need predictable network usage. - -The daemon exits cleanly on `SIGINT` or `SIGTERM`. - -```bash -# LiveSync mode (default — _changes feed, near-real-time) -livesync-cli /path/to/vault - -# Polling mode — poll every 60 seconds -livesync-cli /path/to/vault --interval 60 -``` - -### .livesync/ignore - -Place a `.livesync/ignore` file in your vault root to exclude files from sync in both directions (local → CouchDB and CouchDB → local). - -**Format:** - -- Lines beginning with `#` are comments. -- Blank lines are ignored. -- All other lines are [minimatch](https://github.com/isaacs/minimatch) glob patterns, relative to the vault root. -- The directive `import: .gitignore` (exactly this string) reads `.gitignore` from the vault root and merges its non-comment, non-blank lines into the ignore rules. -- Negation patterns (lines starting with `!`) are not supported and will cause an error on load. - -**Example `.livesync/ignore`:** - -``` -# Ignore temporary files -*.tmp -*.swp - -# Ignore build output -build/ -dist/ - -# Merge patterns from .gitignore -import: .gitignore -``` - -Patterns apply in both directions: the chokidar watcher will not emit events for matched files, and the `isTargetFile` filter will exclude them from CouchDB → local sync. - -Changes to this file require a daemon restart to take effect. - -### Systemd Installation - -The `deploy/` directory contains a systemd unit template and an install script. - -**Automated install (user service, recommended):** - -```bash -bash src/apps/cli/deploy/install.sh --vault /path/to/vault -``` - -**With polling interval:** - -```bash -bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60 -``` - -**System-wide install** (requires root / sudo for `/etc/systemd/system/`): - -```bash -bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault -``` - -The script: -1. Builds the CLI (`npm install` + `npm run build`). -2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system). -3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system). -4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`. - -**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory. - -### Planned options: - -- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`). -- `serve`: Start CLI in server mode, exposing REST APIs for remote, and batch operations. -- `cause-conflicted `: Mark a file as conflicted without changing its content, to trigger conflict resolution in Obsidian. - -## Use Cases - -### 1. Bootstrap a new headless vault - -Create default settings, apply a setup URI, then run one sync cycle. - -```bash -livesync-cli -- init-settings /data/livesync-settings.json -printf '%s\n' "$SETUP_PASSPHRASE" | livesync-cli -- /data/vault --settings /data/livesync-settings.json setup "$SETUP_URI" -livesync-cli -- /data/vault --settings /data/livesync-settings.json sync -``` - -### 2. Scripted import and export - -Push local files into the database from automation, and pull them back for export or backup. - -```bash -livesync-cli -- /data/vault --settings /data/livesync-settings.json push ./note.md notes/note.md -livesync-cli -- /data/vault --settings /data/livesync-settings.json pull notes/note.md ./exports/note.md -``` - -### 3. Revision inspection and restore - -List metadata, find an older revision, then restore it by content (`cat-rev`) or file output (`pull-rev`). - -```bash -livesync-cli -- /data/vault --settings /data/livesync-settings.json info notes/note.md -livesync-cli -- /data/vault --settings /data/livesync-settings.json cat-rev notes/note.md 3-abcdef -livesync-cli -- /data/vault --settings /data/livesync-settings.json pull-rev notes/note.md ./restore/note.old.md 3-abcdef -``` - -### 4. Conflict and cleanup workflow - -Inspect conflicted revisions, resolve by keeping one revision, then delete obsolete files. - -```bash -livesync-cli -- /data/vault --settings /data/livesync-settings.json info notes/note.md -livesync-cli -- /data/vault --settings /data/livesync-settings.json resolve notes/note.md 3-abcdef -livesync-cli -- /data/vault --settings /data/livesync-settings.json rm notes/obsolete.md -``` - -### 5. CI smoke test for content round-trip - -Validate that `put`/`cat` is behaving as expected in a pipeline. - -```bash -echo "hello-ci" | livesync-cli -- /data/vault --settings /data/livesync-settings.json put ci/test.md -livesync-cli -- /data/vault --settings /data/livesync-settings.json cat ci/test.md -``` - -## Development - -### Project Structure - -``` -src/apps/cli/ -├── commands/ # Command dispatcher and command utilities -│ ├── runCommand.ts -│ ├── runCommand.unit.spec.ts -│ ├── types.ts -│ ├── utils.ts -│ └── utils.unit.spec.ts -├── adapters/ # Node.js FileSystem Adapter -│ ├── NodeConversionAdapter.ts -│ ├── NodeFileSystemAdapter.ts -│ ├── NodePathAdapter.ts -│ ├── NodeStorageAdapter.ts -│ ├── NodeStorageAdapter.unit.spec.ts -│ ├── NodeTypeGuardAdapter.ts -│ ├── NodeTypes.ts -│ └── NodeVaultAdapter.ts -├── lib/ -│ └── pouchdb-node.ts -├── managers/ # CLI-specific managers -│ ├── CLIStorageEventManagerAdapter.ts -│ └── StorageEventManagerCLI.ts -├── serviceModules/ # Service modules (ported from main.ts) -│ ├── CLIServiceModules.ts -│ ├── DatabaseFileAccess.ts -│ ├── FileAccessCLI.ts -│ └── ServiceFileAccessImpl.ts -├── services/ -│ ├── NodeKeyValueDBService.ts -│ ├── NodeServiceHub.ts -│ └── NodeSettingService.ts -├── test/ -│ ├── test-e2e-two-vaults-common.sh -│ ├── test-e2e-two-vaults-matrix.sh -│ ├── test-e2e-two-vaults-with-docker-linux.sh -│ ├── test-push-pull-linux.sh -│ ├── test-setup-put-cat-linux.sh -│ └── test-sync-two-local-databases-linux.sh -├── .gitignore -├── entrypoint.ts # CLI executable entry point (shebang) -├── main.ts # CLI entry point -├── main.unit.spec.ts -├── package.json -├── README.md # This file -├── tsconfig.json -├── util/ # Test and local utility scripts -└── vite.config.ts -``` +# Self-hosted LiveSync CLI +Command-line version of Self-hosted LiveSync plug-in for syncing vaults without Obsidian. + +## Features + +- ✅ Sync Obsidian vaults using CouchDB without running Obsidian +- ✅ Compatible with Self-hosted LiveSync plug-in settings +- ✅ Supports all core sync features (encryption, conflict resolution, etc.) +- ✅ Lightweight and headless operation +- ✅ Cross-platform (Windows, macOS, Linux) + +## Architecture + +This CLI version is built using the same core as the Obsidian plug-in: + +``` +CLI Main + └─ LiveSyncBaseCore + ├─ NodeServiceHub (All services without Obsidian dependencies) + └─ ServiceModules (wired by initialiseServiceModulesCLI) + ├─ FileAccessCLI (Node.js FileSystemAdapter) + ├─ StorageEventManagerCLI + ├─ ServiceFileAccessCLI + ├─ ServiceDatabaseFileAccessCLI + ├─ ServiceFileHandler + └─ ServiceRebuilder +``` + +### Key Components + +1. **Node.js FileSystem Adapter** (`adapters/`) + - Platform-agnostic file operations using Node.js `fs/promises` + - Implements same interface as Obsidian's file system + +2. **Service Modules** (`serviceModules/`) + - Initialised by `initialiseServiceModulesCLI` + - All core sync functionality preserved + +3. **Service Hub and Settings Services** (`services/`) + - `NodeServiceHub` provides the CLI service context + - Node-specific settings and key-value services are provided without Obsidian dependencies + +4. **Main Entry Point** (`main.ts`) + - Command-line interface + - Settings management (JSON file) + - Graceful shutdown handling + +## Usage + +The CLI operates on a **database directory** which contains PouchDB data and settings. + +> [!NOTE] +> `livesync-cli` is the alias for the CLI executable. Please replace with the actual command of your installation (e.g. `npm run --silent cli --` or `docker run ...`). + +```bash +livesync-cli [database-path] [command] [args...] +``` + + +### Arguments + +- `database-path`: Path to the directory where `.livesync` folder and `settings.json` are (or will be) located. + - Note: In previous versions, this was referred to as the "vault" path. Now it is clearly distinguished from the actual vault (the directory containing your `.md` files). +- `--vault ` / `-V `: (daemon/mirror only) Path to the vault directory containing `.md` files. + - Allows the PouchDB database directory and the actual vault directory to be different locations. + - For `mirror` command, the positional `[vault-path]` argument takes precedence over `--vault`. + +### Commands + +- `sync`: Run one replication cycle with the remote CouchDB. +- `mirror [vault-path]`: Bidirectional sync between the local database and a local directory (**the actual vault**). + - If `vault-path` is provided, the CLI will synchronise the database with files in the vault directory. + - If `vault-path` is omitted, it defaults to `database-path` (compatibility mode). + - Use this command to keep your local `.md` files in sync with the database. +- `ls [prefix]`: List files currently stored in the local database. +- `push `: Push a local file `` into the database at path ``. +- `pull `: Pull a file `` from the database into local file ``. +- `cat `: Read a file from the database and write to stdout. +- `put `: Read from stdin and write to the database path ``. +- `remote-add `: Add a remote configuration from a connection string. +- `remote-rm `: Remove a remote configuration by ID. +- `remote-ls`: List remote configurations (`id`, `name`, `active/inactive`, redacted URI). +- `remote-export `: Export the stored connection string by remote ID. +- `remote-set `: Replace the stored connection string by remote ID. +- `remote-activate `: Activate a remote configuration by ID. +- `mark-resolved [remote-id]`: Resolve remote synchronisation status. +- `unlock-remote [remote-id]`: Unlock the remote database. +- `lock-remote [remote-id]`: Lock the remote database. +- `remote-status [remote-id]`: Show remote database status. +- `init-settings [file]`: Create a default settings file. + +### Examples + +```bash +# Basic sync with remote +livesync-cli ./my-db sync + +# Mirroring to your actual Obsidian vault +livesync-cli ./my-db mirror /path/to/obsidian-vault + +# Manual file operations +livesync-cli ./my-db push ./note.md folder/note.md +livesync-cli ./my-db pull folder/note.md ./note.md +``` + +## Installation + +### Build from source + +```bash +# Clone with submodules, because the shared core lives in src/lib +git clone --recurse-submodules +cd obsidian-livesync + +# If you already cloned without submodules, run this once instead +git submodule update --init --recursive + +# Install dependencies from the repository root +npm install + +# Build the CLI from the repository root +npm run build -w self-hosted-livesync-cli + +# Or from the package directory +cd src/apps/cli +npm run build +``` + +If `src/lib` is missing, the build process stops early with a targeted message instead of a low-level Vite `ENOENT` error. + +Run the CLI: + +```bash +# Run with npm workspace script (from repository root) +npm run cli -w self-hosted-livesync-cli -- [database-path] [command] [args...] + +# Or from the package directory +cd src/apps/cli +npm run cli -- [database-path] [command] [args...] + +# Run the built executable directly +node src/apps/cli/dist/index.cjs [database-path] [command] [args...] +``` + +### Docker + +A Docker image is provided for headless / server deployments. Build from the repository root: + +```bash +docker build -f src/apps/cli/Dockerfile -t livesync-cli . +``` + +Run: + +```bash +# Sync with CouchDB +docker run --rm -v /path/to/your/db:/data livesync-cli sync + +# Mirror to a specific vault directory +docker run --rm -v /path/to/your/db:/data -v /path/to/your/vault:/vault livesync-cli mirror /vault + +# List files in the local database +docker run --rm -v /path/to/your/db:/data livesync-cli ls +``` + +The database directory is mounted at `/data` by default. Override with `-e LIVESYNC_DB_PATH=/other/path`. + +#### P2P (WebRTC) and Docker networking + +The P2P replicator (`p2p-host`, `p2p-sync`, `p2p-peers`) uses WebRTC and generates +three kinds of ICE candidates. The default Docker bridge network affects which +candidates are usable: + +| Candidate type | Description | Bridge network | +| -------------- | ---------------------------------- | -------------------------- | +| `host` | Container bridge IP (`172.17.x.x`) | Unreachable from LAN peers | +| `srflx` | Host public IP via STUN reflection | Works over the internet | +| `relay` | Traffic relayed via TURN server | Always reachable | + +**LAN P2P on Linux** — use `--network host` so that the real host IP is +advertised as the `host` candidate: + +```bash +docker run --rm --network host -v /path/to/your/vault:/data livesync-cli p2p-host +``` + +Note: also fix the alias to include `--network host` if you want to use `livesync-cli` for P2P commands. + +> `--network host` is not available on Docker Desktop for macOS or Windows. + +**LAN P2P on macOS / Windows Docker Desktop** — configure a TURN server in the +settings file (`P2P_turnServers`, `P2P_turnUsername`, `P2P_turnCredential`). +All P2P traffic will then be relayed through the TURN server, bypassing the +bridge-network limitation. + +**Internet P2P** — the default bridge network is sufficient. The `srflx` +candidate carries the host's public IP and peers can connect normally. + +**CouchDB sync only (no P2P)** — no special network configuration is required. + + +### Adding `livesync-cli` alias + +To use the `livesync-cli` command globally, you can add an alias to your shell configuration file (e.g., `.zshrc` or `.bashrc`). + +If you are using `npm run`, add the following line: + +```bash +alias livesync-cli='npm run --silent --prefix /path/to/repository/src/apps/cli cli --' +# or +alias livesync-cli="npm run --silent --prefix $PWD cli --" +``` + +Alternatively, if you want to use the built executable directly: + +```bash +alias livesync-cli='node /path/to/repository/src/apps/cli/dist/index.cjs' +or +alias livesync-cli="node $PWD/dist/index.cjs" +``` + +If you prefer using Docker: + +```bash +alias livesync-cli='docker run --rm -v /path/to/your/db:/data livesync-cli' +``` + +After adding the alias, restart your shell or run `source ~/.zshrc` (or `.bashrc`). + +## Usage + +### Basic Usage + +As you know, the CLI is designed to be used in a headless environment. Hence all operations are performed against a local vault directory and a settings file. Here are some example commands: + +```bash +# Sync local database with CouchDB (no files will be changed). +livesync-cli /path/to/your-local-database --settings /path/to/settings.json sync + +# Push files to local database +livesync-cli /path/to/your-local-database --settings /path/to/settings.json push /your/storage/file.md /vault/path/file.md + +# Pull files from local database +livesync-cli /path/to/your-local-database --settings /path/to/settings.json pull /vault/path/file.md /your/storage/file.md + +# Verbose logging +livesync-cli /path/to/your-local-database --settings /path/to/settings.json --verbose + +# Apply setup URI to settings file (settings only; does not run synchronisation) +livesync-cli /path/to/your-local-database --settings /path/to/settings.json setup "obsidian://setuplivesync?settings=..." + +# Put text from stdin into local database +echo "Hello from stdin" | livesync-cli /path/to/your-local-database --settings /path/to/settings.json put /vault/path/file.md + +# Output a file from local database to stdout +livesync-cli /path/to/your-local-database --settings /path/to/settings.json cat /vault/path/file.md + +# Output a specific revision of a file from local database +livesync-cli /path/to/your-local-database --settings /path/to/settings.json cat-rev /vault/path/file.md 3-abcdef + +# Pull a specific revision of a file from local database to local storage +livesync-cli /path/to/your-local-database --settings /path/to/settings.json pull-rev /vault/path/file.md /your/storage/file.old.md 3-abcdef + +# List files in local database +livesync-cli /path/to/your-local-database --settings /path/to/settings.json ls /vault/path/ + +# Show metadata for a file in local database +livesync-cli /path/to/your-local-database --settings /path/to/settings.json info /vault/path/file.md + +# Mark a file as deleted in local database +livesync-cli /path/to/your-local-database --settings /path/to/settings.json rm /vault/path/file.md + +# Resolve conflict by keeping a specific revision +livesync-cli /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef + +# Add, list, activate, and remove remote configurations +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-add main "sls+https://user:pass@example.com/db" +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-ls +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-export remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-set remote-abc123 "sls+p2p://room-abc?passphrase=secret" +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-activate remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-rm remote-abc123 + +# Lock, unlock, resolve, and view status of remote database +livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-status remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json lock-remote remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json mark-resolved remote-abc123 +livesync-cli /path/to/your-local-database --settings /path/to/settings.json unlock-remote remote-abc123 +``` + +### Configuration + +The CLI uses the same settings format as the Obsidian plug-in. Create a `.livesync/settings.json` file in your vault directory: + +```json +{ + "couchDB_URI": "http://localhost:5984", + "couchDB_USER": "admin", + "couchDB_PASSWORD": "password", + "couchDB_DBNAME": "obsidian-livesync", + "liveSync": true, + "syncOnSave": true, + "syncOnStart": true, + "encrypt": true, + "passphrase": "your-encryption-passphrase", + "usePluginSync": false, + "isConfigured": true +} +``` + +**Minimum required settings:** + +- `couchDB_URI`: CouchDB server URL +- `couchDB_USER`: CouchDB username +- `couchDB_PASSWORD`: CouchDB password +- `couchDB_DBNAME`: Database name +- `isConfigured`: Set to `true` after configuration + +### Command-line Reference + +``` +Usage: + livesync-cli [options] [command-args] + livesync-cli init-settings [path] + +Arguments: + database-path Path to the local database directory (required except for init-settings) + +Options: + --settings, -s Path to settings file (default: .livesync/settings.json in local database directory) + --force, -f Overwrite existing file on init-settings + --verbose, -v Enable verbose logging + --debug, -d Enable debug logging (includes verbose) + --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed + --vault , -V (daemon/mirror) Path to vault directory, decoupled from database-path + --help, -h Show this help message + +Commands: + daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem + init-settings [path] Create settings JSON from DEFAULT_SETTINGS + sync Run one replication cycle and exit + p2p-peers Show discovered peers as [peer] + p2p-sync Synchronise with specified peer-id or peer-name + p2p-host Start P2P host mode and wait until interrupted (Ctrl+C) + push Push local file into local database path + pull Pull file from local database into local file + pull-rev Pull specific revision into local file + setup Apply setup URI to settings file + put Read text from standard input and write to local database path + cat Write latest file content from local database to standard output + cat-rev Write specific revision content from local database to standard output + ls [prefix] List files as pathsizemtimerevision[*] + info Show file metadata including current and past revisions, conflicts, and chunk list + rm Mark file as deleted in local database + resolve Resolve conflict by keeping the specified revision + mirror [vaultPath] Mirror database contents to the local file system + (vaultPath positional arg > --vault flag > database-path) +``` + +Run via npm script: + +```bash +npm run --silent cli -- [database-path] [options] [command] [command-args] +``` + +#### Detailed Command Descriptions + +##### ls +`ls` lists files in the local database with optional prefix filtering. Output format is: + +```vault/path/file.mdsizemtimerevision[*] +``` +Note: `*` indicates if the file has conflicts. + +##### p2p-peers + +`p2p-peers ` waits for the specified number of seconds, then prints each discovered peer on a separate line: + +```text +[peer] +``` + +Use this command to select a target for `p2p-sync`. + +##### p2p-sync + +`p2p-sync ` discovers peers up to the specified timeout and synchronises with the selected peer. + +- `` accepts either `peer-id` or `peer-name` from `p2p-peers` output. +- On success, the command prints a completion message to standard error and exits with status code `0`. +- On failure, the command prints an error message and exits non-zero. + +##### p2p-host + +`p2p-host` starts the local P2P host and keeps running until interrupted. + +- Other peers can discover and synchronise with this host while it is running. +- Stop the host with `Ctrl+C`. +- In CLI mode, behaviour is non-interactive and acceptance follows settings. + +##### info + +`info` output fields: + +- `id`: Document ID +- `revision`: Current revision +- `conflicts`: Conflicted revisions, or `N/A` +- `filename`: Basename of path +- `path`: Vault-relative path +- `size`: Size in bytes +- `revisions`: Available non-current revisions +- `chunks`: Number of chunk IDs +- `children`: Chunk ID list + +##### mirror + +`mirror` is a command that synchronises your storage with your local vault. It is essentially a process that runs upon startup in Obsidian. + +In other words, it performs the following actions: + +1. **Precondition checks** — Aborts early if any of the following conditions are not met: + - Settings must be configured (`isConfigured: true`). + - File watching must not be suspended (`suspendFileWatching: false`). + - Remediation mode must be inactive (`maxMTimeForReflectEvents: 0`). + +2. **State restoration** — On subsequent runs (after the first successful scan), restores the previous storage state before proceeding. + +3. **Expired deletion cleanup** — If `automaticallyDeleteMetadataOfDeletedFiles` is set to a positive number of days, any document that is marked deleted and whose `mtime` is older than the retention period is permanently removed from the local database. + +4. **File collection** — Enumerates files from two sources: + - **Storage**: all files under the vault path that pass `isTargetFile`. + - **Local database**: all normal documents (fetched with conflict information) whose paths are valid and pass `isTargetFile`. + - Both collections build case-insensitive ↔ case-sensitive path maps, controlled by `handleFilenameCaseSensitive`. + +5. **Categorisation and synchronisation** — The union of both file sets is split into three groups and processed concurrently (up to 10 files at a time): + + | Group | Condition | Action | + | ----------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | **UPDATE DATABASE** | File exists in storage only | Store the file into the local database. | + | **UPDATE STORAGE** | File exists in database only | If the entry is active (not deleted) and not conflicted, restore the file from the database to storage. Deleted entries and conflicted entries are skipped. | + | **SYNC DATABASE AND STORAGE** | File exists in both | Compare `mtime` freshness. If storage is newer → write to database (`STORAGE → DB`). If database is newer → restore to storage (`STORAGE ← DB`). If equal → do nothing. Conflicted documents and files exceeding the size limit are always skipped. | + +6. **Initialisation flag** — On the very first successful run, writes `initialized = true` to the key-value database so that subsequent runs can restore state in step 2. + +Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases). + +##### daemon + +`daemon` is the default command when no command is specified. It runs an initial mirror scan and then continuously syncs changes in both directions: + +- **CouchDB → local filesystem**: via the `_changes` feed (LiveSync mode, default) or periodic polling (`--interval N`). +- **local filesystem → CouchDB**: via chokidar file watching. Any file created, modified, or deleted in the vault directory is pushed to CouchDB. + +In **LiveSync mode** the `_changes` feed delivers remote changes as they arrive, with sub-second latency. In **polling mode** (`--interval N`) the CLI polls CouchDB every N seconds. Use polling mode if your CouchDB instance does not support long-lived HTTP connections, or if you need predictable network usage. + +The daemon exits cleanly on `SIGINT` or `SIGTERM`. + +```bash +# LiveSync mode (default — _changes feed, near-real-time) +livesync-cli /path/to/vault + +# Polling mode — poll every 60 seconds +livesync-cli /path/to/vault --interval 60 +``` + +### .livesync/ignore + +Place a `.livesync/ignore` file in your vault root to exclude files from sync in both directions (local → CouchDB and CouchDB → local). + +**Format:** + +- Lines beginning with `#` are comments. +- Blank lines are ignored. +- All other lines are [minimatch](https://github.com/isaacs/minimatch) glob patterns, relative to the vault root. +- The directive `import: .gitignore` (exactly this string) reads `.gitignore` from the vault root and merges its non-comment, non-blank lines into the ignore rules. +- Negation patterns (lines starting with `!`) are not supported and will cause an error on load. + +**Example `.livesync/ignore`:** + +``` +# Ignore temporary files +*.tmp +*.swp + +# Ignore build output +build/ +dist/ + +# Merge patterns from .gitignore +import: .gitignore +``` + +Patterns apply in both directions: the chokidar watcher will not emit events for matched files, and the `isTargetFile` filter will exclude them from CouchDB → local sync. + +Changes to this file require a daemon restart to take effect. + +### Systemd Installation + +The `deploy/` directory contains a systemd unit template and an install script. + +**Automated install (user service, recommended):** + +```bash +bash src/apps/cli/deploy/install.sh --vault /path/to/vault +``` + +**With polling interval:** + +```bash +bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60 +``` + +**System-wide install** (requires root / sudo for `/etc/systemd/system/`): + +```bash +bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault +``` + +The script: +1. Builds the CLI (`npm install` + `npm run build`). +2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system). +3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system). +4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`. + +**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory. + +### Planned options: + +- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`). +- `serve`: Start CLI in server mode, exposing REST APIs for remote, and batch operations. +- `cause-conflicted `: Mark a file as conflicted without changing its content, to trigger conflict resolution in Obsidian. + +## Use Cases + +### 1. Bootstrap a new headless vault + +Create default settings, apply a setup URI, then run one sync cycle. + +```bash +livesync-cli -- init-settings /data/livesync-settings.json +printf '%s\n' "$SETUP_PASSPHRASE" | livesync-cli -- /data/vault --settings /data/livesync-settings.json setup "$SETUP_URI" +livesync-cli -- /data/vault --settings /data/livesync-settings.json sync +``` + +### 2. Scripted import and export + +Push local files into the database from automation, and pull them back for export or backup. + +```bash +livesync-cli -- /data/vault --settings /data/livesync-settings.json push ./note.md notes/note.md +livesync-cli -- /data/vault --settings /data/livesync-settings.json pull notes/note.md ./exports/note.md +``` + +### 3. Revision inspection and restore + +List metadata, find an older revision, then restore it by content (`cat-rev`) or file output (`pull-rev`). + +```bash +livesync-cli -- /data/vault --settings /data/livesync-settings.json info notes/note.md +livesync-cli -- /data/vault --settings /data/livesync-settings.json cat-rev notes/note.md 3-abcdef +livesync-cli -- /data/vault --settings /data/livesync-settings.json pull-rev notes/note.md ./restore/note.old.md 3-abcdef +``` + +### 4. Conflict and cleanup workflow + +Inspect conflicted revisions, resolve by keeping one revision, then delete obsolete files. + +```bash +livesync-cli -- /data/vault --settings /data/livesync-settings.json info notes/note.md +livesync-cli -- /data/vault --settings /data/livesync-settings.json resolve notes/note.md 3-abcdef +livesync-cli -- /data/vault --settings /data/livesync-settings.json rm notes/obsolete.md +``` + +### 5. CI smoke test for content round-trip + +Validate that `put`/`cat` is behaving as expected in a pipeline. + +```bash +echo "hello-ci" | livesync-cli -- /data/vault --settings /data/livesync-settings.json put ci/test.md +livesync-cli -- /data/vault --settings /data/livesync-settings.json cat ci/test.md +``` + +## Development + +### Project Structure + +``` +src/apps/cli/ +├── commands/ # Command dispatcher and command utilities +│ ├── runCommand.ts +│ ├── runCommand.unit.spec.ts +│ ├── types.ts +│ ├── utils.ts +│ └── utils.unit.spec.ts +├── adapters/ # Node.js FileSystem Adapter +│ ├── NodeConversionAdapter.ts +│ ├── NodeFileSystemAdapter.ts +│ ├── NodePathAdapter.ts +│ ├── NodeStorageAdapter.ts +│ ├── NodeStorageAdapter.unit.spec.ts +│ ├── NodeTypeGuardAdapter.ts +│ ├── NodeTypes.ts +│ └── NodeVaultAdapter.ts +├── lib/ +│ └── pouchdb-node.ts +├── managers/ # CLI-specific managers +│ ├── CLIStorageEventManagerAdapter.ts +│ └── StorageEventManagerCLI.ts +├── serviceModules/ # Service modules (ported from main.ts) +│ ├── CLIServiceModules.ts +│ ├── DatabaseFileAccess.ts +│ ├── FileAccessCLI.ts +│ └── ServiceFileAccessImpl.ts +├── services/ +│ ├── NodeKeyValueDBService.ts +│ ├── NodeServiceHub.ts +│ └── NodeSettingService.ts +├── test/ +│ ├── test-e2e-two-vaults-common.sh +│ ├── test-e2e-two-vaults-matrix.sh +│ ├── test-e2e-two-vaults-with-docker-linux.sh +│ ├── test-push-pull-linux.sh +│ ├── test-setup-put-cat-linux.sh +│ └── test-sync-two-local-databases-linux.sh +├── .gitignore +├── entrypoint.ts # CLI executable entry point (shebang) +├── main.ts # CLI entry point +├── main.unit.spec.ts +├── package.json +├── README.md # This file +├── tsconfig.json +├── util/ # Test and local utility scripts +└── vite.config.ts +``` diff --git a/src/serviceFeatures/configSync/README.md b/src/serviceFeatures/configSync/README.md index 396b2e4..5301df5 100644 --- a/src/serviceFeatures/configSync/README.md +++ b/src/serviceFeatures/configSync/README.md @@ -1,6 +1,6 @@ # Configuration Synchronisation (`configSync`) -This module manages the synchronisation of Obsidian configurations, particularly plug-in lists, plug-in manifests, and plug-in data (such as settings JSON files). It refactors the monolithic implementation of `CmdConfigSync.ts` into a set of decoupled, side-effect-free, and highly testable functions. +This module manages the synchronisation of Obsidian configurations, particularly plug-in lists, plug-in manifests, and plug-in data (such as settings JSON files). It refactors the monolithic implementation of `CmdConfigSync.ts` into a set of decoupled, dependency-explicit, and highly testable functions. ## Module Structure @@ -13,7 +13,7 @@ graph TD index.ts --> state.ts["state.ts (ConfigSyncState)"] index.ts --> pluginScanner.ts["pluginScanner.ts"] index.ts --> syncOperations.ts["syncOperations.ts"] - + pluginScanner.ts --> utils.ts["utils.ts"] pluginScanner.ts --> stores.ts["stores.ts"] syncOperations.ts --> utils.ts @@ -28,22 +28,25 @@ graph TD - **`pluginScanner.ts`**: Scans the vault for installed plug-ins, parses manifests, detects active states, and updates Svelte stores. - **`syncOperations.ts`**: Implements synchronisation tasks including comparing local plug-in configurations with the database, downloading/uploading configurations, and watching vault events. - **`eventBindings.ts`**: Registers event handlers to Obsidian and other internal services (e.g., reacting to plug-in lifecycle events). -- **`commands.ts`**: Registers ribbon commands and command palette items (e.g., opening the plugin synchronisation dialogue). +- **`commands.ts`**: Registers ribbon commands and command palette items (e.g., opening the plug-in synchronisation dialogue). ## Key Workflows ### Plug-in Scanning & Enumeration + 1. Scans the `.obsidian/plugins/` directory to discover all installed plug-ins. 2. Reads and parses their `manifest.json` files and caches the results in `pluginManifests`. 3. Checks which plug-ins are currently enabled or disabled. 4. Aggregates this information into `pluginList` for UI dialogues. ### Configuration Synchronisation (Upload and Download) + 1. Fetches configuration changes from the remote database or checks local settings files. 2. Converts filepath configurations to unified key formats (e.g. prefixing with `ix:`, stripping system-specific paths). 3. Detects modifications using hash and modification time (`mtime`) comparisons. 4. Performs silent or interactive updates depending on the user's setting, applying configuration files back to the storage and triggering hot-reloading if necessary. ### Real-time Event Monitoring + 1. Observes file changes in configuration directories using Obsidian vault events. 2. Hooks into plug-in activation/deactivation events to trigger synchronisation sweeps automatically. diff --git a/src/serviceFeatures/configSync/eventBindings.ts b/src/serviceFeatures/configSync/eventBindings.ts index 9e9ed47..3f34c2a 100644 --- a/src/serviceFeatures/configSync/eventBindings.ts +++ b/src/serviceFeatures/configSync/eventBindings.ts @@ -195,8 +195,7 @@ export function bindConfigSyncEvents( const message = `Would you like to enable **Customisation sync**? > [!DETAILS]- -> This feature allows you to sync your customisations -- such as configurations, themes, snippets, and plugins -- across your devices in a fully controlled manner, unlike the fully automatic behaviour of hidden file synchronisation. -> +> This feature allows you to sync your customisations -- such as configurations, themes, snippets, and plug-ins -- across your devices in a fully controlled manner, unlike the fully automatic behaviour of hidden file synchronisation. > You may use this feature alongside hidden file synchronisation. When both features are enabled, items configured as \`Automatic\` in this feature will be managed by **hidden file synchronisation**. > Do not worry, you will be prompted to enable or keep disabled **hidden file synchronisation** after this dialogue. `; diff --git a/src/serviceFeatures/conflictResolution/conflictChecker.ts b/src/serviceFeatures/conflictResolution/conflictChecker.ts index 54833e7..addf8b9 100644 --- a/src/serviceFeatures/conflictResolution/conflictChecker.ts +++ b/src/serviceFeatures/conflictResolution/conflictChecker.ts @@ -1,20 +1,25 @@ import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "@lib/common/types"; -import { Logger } from "octagonal-wheels/common/logger"; import { QueueProcessor } from "octagonal-wheels/concurrency/processor"; import { sendValue } from "octagonal-wheels/messagepassing/signal"; import type { NecessaryObsidianFeature } from "@/types"; +import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils"; -export type ConflictCheckerHost = NecessaryObsidianFeature<"conflict" | "vault" | "setting">; +export type ConflictCheckerHost = NecessaryObsidianFeature<"API" | "conflict" | "vault" | "setting">; + +const noopLog: LogFunction = () => undefined; +const createConflictCheckerLog = (host: ConflictCheckerHost): LogFunction => + host.services.API ? createInstanceLogFunction("ConflictChecker", host.services.API) : noopLog; export const queueConflictCheckIfOpenHandler = async ( host: ConflictCheckerHost, file: FilePathWithPrefix ): Promise => { + const log = createConflictCheckerLog(host); const path = file; if (host.services.setting.settings.checkConflictOnlyOnOpen) { const af = host.services.vault.getActiveFilePath(); if (af && af != path) { - Logger(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE); + log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE); return; } } diff --git a/src/serviceFeatures/conflictResolution/conflictChecker.unit.spec.ts b/src/serviceFeatures/conflictResolution/conflictChecker.unit.spec.ts index f1e9445..5b50675 100644 --- a/src/serviceFeatures/conflictResolution/conflictChecker.unit.spec.ts +++ b/src/serviceFeatures/conflictResolution/conflictChecker.unit.spec.ts @@ -30,6 +30,11 @@ describe("conflictChecker", () => { mockHub.services.vault.getActiveFilePath = vi.fn().mockReturnValue("other.md"); await queueConflictCheckIfOpenHandler(mockHub as any, "test.md" as any); expect(mockHub.services.conflict.queueCheckFor).not.toHaveBeenCalled(); + expect(mockHub.services.API.addLog).toHaveBeenCalledWith( + expect.stringContaining("test.md"), + expect.any(Number), + "" + ); }); it("queueConflictCheckIfOpenHandler should queue if checkConflictOnlyOnOpen is false", async () => { diff --git a/src/serviceFeatures/conflictResolution/conflictResolver.ts b/src/serviceFeatures/conflictResolution/conflictResolver.ts index 476be3c..e8c5d5b 100644 --- a/src/serviceFeatures/conflictResolution/conflictResolver.ts +++ b/src/serviceFeatures/conflictResolution/conflictResolver.ts @@ -16,8 +16,8 @@ import { compareMTime, displayRev } from "@lib/common/utils.ts"; import diff_match_patch from "diff-match-patch"; import { stripAllPrefixes, isPlainText } from "@lib/string_and_binary/path"; import { eventHub } from "@/common/events.ts"; -import { Logger } from "octagonal-wheels/common/logger"; import type { NecessaryObsidianFeature } from "@/types"; +import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils"; declare global { interface LSEvents { @@ -26,10 +26,14 @@ declare global { } export type ConflictResolverHost = NecessaryObsidianFeature< - "conflict" | "appLifecycle" | "replication" | "vault" | "setting" | "database", + "API" | "conflict" | "appLifecycle" | "replication" | "vault" | "setting" | "database", "databaseFileAccess" | "fileHandler" | "storageAccess" >; +const noopLog: LogFunction = () => undefined; +const createConflictResolverLog = (host: ConflictResolverHost): LogFunction => + host.services.API ? createInstanceLogFunction("ConflictResolver", host.services.API) : noopLog; + export const resolveConflictByDeletingRevHandler = async ( host: ConflictResolverHost, path: FilePathWithPrefix, @@ -37,31 +41,29 @@ export const resolveConflictByDeletingRevHandler = async ( subTitle = "" ): Promise => { const { serviceModules } = host; + const log = createConflictResolverLog(host); const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`; if (!(await serviceModules.fileHandler.deleteRevisionFromDB(path, deleteRevision))) { - Logger( - `${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`, - LOG_LEVEL_NOTICE - ); + log(`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`, LOG_LEVEL_NOTICE); return MISSING_OR_ERROR; } eventHub.emitEvent("conflict-cancelled", path); - Logger(`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`, LOG_LEVEL_INFO); + log(`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`, LOG_LEVEL_INFO); if ((await serviceModules.databaseFileAccess.getConflictedRevs(path)).length != 0) { - Logger(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO); + log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO); return AUTO_MERGED; } if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) { - Logger(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO); + log(`${title} ${path} is a plug-in metadata file, no need to write to storage`, LOG_LEVEL_INFO); return AUTO_MERGED; } // If no conflicts were found, write the resolved content to the storage. if (!(await serviceModules.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) { - Logger(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE); + log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE); return MISSING_OR_ERROR; } const level = subTitle.indexOf("same") !== -1 ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE; - Logger(`${path} has been merged automatically`, level); + log(`${path} has been merged automatically`, level); return AUTO_MERGED; }; @@ -70,6 +72,7 @@ export const checkConflictAndPerformAutoMerge = async ( path: FilePathWithPrefix ): Promise => { const { services, serviceModules } = host; + const log = createConflictResolverLog(host); const settings = services.setting.settings; const ret = await services.database.localDatabase.tryAutoMerge(path, !settings.disableMarkdownAutoMerge); @@ -82,7 +85,7 @@ export const checkConflictAndPerformAutoMerge = async ( // Merged content is coming. // 1. Store the merged content to the storage if (!(await serviceModules.databaseFileAccess.storeContent(path, p))) { - Logger(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE); + log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE); return MISSING_OR_ERROR; } // 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage. @@ -94,7 +97,7 @@ export const checkConflictAndPerformAutoMerge = async ( // should be one or more conflicts; if (leftLeaf == false) { // what's going on.. - Logger(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE); + log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE); return MISSING_OR_ERROR; } if (rightLeaf == false) { @@ -124,7 +127,7 @@ export const checkConflictAndPerformAutoMerge = async ( const dmp = new diff_match_patch(); const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data); dmp.diff_cleanupSemantic(diff); - Logger(`conflict(s) found:${path}`); + log(`conflict(s) found:${path}`); return { left: leftLeaf, right: rightLeaf, @@ -137,6 +140,7 @@ export const resolveConflictHandler = async ( filename: FilePathWithPrefix ): Promise => { const { services } = host; + const log = createConflictResolverLog(host); const settings = services.setting.settings; return await serialized(`conflict-resolve:${filename}`, async () => { @@ -147,7 +151,7 @@ export const resolveConflictHandler = async ( conflictCheckResult === CANCELLED ) { // nothing to do. - Logger(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE); + log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE); return; } if (conflictCheckResult === AUTO_MERGED) { @@ -156,21 +160,21 @@ export const resolveConflictHandler = async ( //Wait for the running replication, if not running replication, run it once. await services.replication.replicateByEvent(); } - Logger("[conflict] Automatically merged, but we have to check it again"); + log("[conflict] Automatically merged, but we have to check it again"); await services.conflict.queueCheckFor(filename); return; } if (settings.showMergeDialogOnlyOnActive) { const af = services.vault.getActiveFilePath(); if (af && af != filename) { - Logger( + log( `[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`, LOG_LEVEL_NOTICE ); return; } } - Logger("[conflict] Manual merge required!"); + log("[conflict] Manual merge required!"); eventHub.emitEvent("conflict-cancelled", filename); await services.conflict.resolveByUserInteraction(filename, conflictCheckResult); }); @@ -181,9 +185,10 @@ export const resolveConflictByNewestHandler = async ( filename: FilePathWithPrefix ): Promise => { const { services, serviceModules } = host; + const log = createConflictResolverLog(host); const currentRev = await serviceModules.databaseFileAccess.fetchEntryMeta(filename, undefined, true); if (currentRev == false) { - Logger(`Could not get current revision of ${filename}`); + log(`Could not get current revision of ${filename}`); return Promise.resolve(false); } const revs = await serviceModules.databaseFileAccess.getConflictedRevs(filename); @@ -210,11 +215,11 @@ export const resolveConflictByNewestHandler = async ( } return diff; }); - Logger( + log( `Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)` ); for (let i = 1; i < mTimeAndRev.length; i++) { - Logger( + log( `conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}` ); await services.conflict.resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST"); @@ -224,14 +229,15 @@ export const resolveConflictByNewestHandler = async ( export const resolveAllConflictedFilesByNewerOnesHandler = async (host: ConflictResolverHost) => { const { services, serviceModules } = host; - Logger(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE); + const log = createConflictResolverLog(host); + log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE); const files = await serviceModules.storageAccess.getFileNames(); let i = 0; for (const file of files) { if (i++ % 10) { - Logger( + log( `Check and Processing ${i} / ${files.length}`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes" @@ -239,7 +245,7 @@ export const resolveAllConflictedFilesByNewerOnesHandler = async (host: Conflict } await services.conflict.resolveByNewest(file); } - Logger(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes"); + log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes"); }; export function useConflictResolver(host: ConflictResolverHost) { diff --git a/src/serviceFeatures/conflictResolution/conflictResolver.unit.spec.ts b/src/serviceFeatures/conflictResolution/conflictResolver.unit.spec.ts index 43d8914..dc8a936 100644 --- a/src/serviceFeatures/conflictResolution/conflictResolver.unit.spec.ts +++ b/src/serviceFeatures/conflictResolution/conflictResolver.unit.spec.ts @@ -36,6 +36,11 @@ describe("conflictResolver", () => { }; const res = await resolveConflictByDeletingRevHandler(mockHub as any, "test.md" as any, "1-abc"); expect(res).toBe(MISSING_OR_ERROR); + expect(mockHub.services.API.addLog).toHaveBeenCalledWith( + expect.stringContaining("Could not delete conflicted revision"), + expect.any(Number), + "" + ); }); it("resolveConflictByDeletingRevHandler should return early with AUTO_MERGED if conflicts are left", async () => { diff --git a/src/serviceFeatures/databaseMaintenance/README.md b/src/serviceFeatures/databaseMaintenance/README.md index c9ab6c3..51877b4 100644 --- a/src/serviceFeatures/databaseMaintenance/README.md +++ b/src/serviceFeatures/databaseMaintenance/README.md @@ -1,6 +1,6 @@ # Database Maintenance (`databaseMaintenance`) -This module manages database maintenance operations, including garbage collection of unused chunks, remote database compaction, and database usage diagnostics. It refactors the monolithic implementation of `CmdLocalDatabaseMainte.ts` into a set of decoupled, side-effect-free, and highly testable functions. +This module manages database maintenance operations, including garbage collection of unused chunks, remote database compaction, and database usage diagnostics. It refactors the monolithic implementation of `CmdLocalDatabaseMainte.ts` into a set of decoupled, dependency-explicit, and highly testable functions. ## Module Structure @@ -13,7 +13,7 @@ graph TD index.ts --> garbageCollection.ts["garbageCollection.ts"] index.ts --> compaction.ts["compaction.ts"] index.ts --> diagnostics.ts["diagnostics.ts"] - + garbageCollection.ts --> utils.ts["utils.ts"] compaction.ts --> utils.ts diagnostics.ts --> utils.ts @@ -30,6 +30,7 @@ graph TD ## Key Workflows ### Garbage Collection V3 (`gcv3`) + 1. Syncs latest modifications via one-shot replication. 2. Compares progress values across all connected nodes to ensure they are synchronised. 3. Scans all active database entries to compile a set of referenced chunks. @@ -37,6 +38,7 @@ graph TD 5. Invokes database compaction to release physical storage. ### Database Usage Audit (`analyseDatabase`) + 1. Fetches all document revisions from the local database. 2. Iterates and logs chunk relationships, sorting them into unique, shared, and orphaned categories. 3. Calculates aggregated sizes and compiles details into a TSV format. diff --git a/src/serviceFeatures/hiddenFileSync/README.md b/src/serviceFeatures/hiddenFileSync/README.md index 0f93ab9..02aef2c 100644 --- a/src/serviceFeatures/hiddenFileSync/README.md +++ b/src/serviceFeatures/hiddenFileSync/README.md @@ -1,6 +1,6 @@ -# Hidden File Synchronization (`hiddenFileSync`) +# Hidden File Synchronisation (`hiddenFileSync`) -This module manages the synchronization of Obsidian configuration directories and files (hidden files prefix with `.`, excluding `.trash`). It detaches the monolithic implementation of `CmdHiddenFileSync.ts` into a set of decoupled, side-effect-free, and highly testable functions. +This module manages the synchronisation of Obsidian configuration directories and files (hidden files prefixed with `.`, excluding `.trash`). It detaches the monolithic implementation of `CmdHiddenFileSync.ts` into a set of decoupled, dependency-explicit, and highly testable functions. ## Module Structure @@ -15,7 +15,7 @@ graph TD index.ts --> syncOperations.ts["syncOperations.ts"] index.ts --> rebuild.ts["rebuild.ts"] index.ts --> conflictResolution.ts["conflictResolution.ts"] - + syncOperations.ts --> databaseIO.ts["databaseIO.ts"] syncOperations.ts --> stateHelpers.ts["stateHelpers.ts"] rebuild.ts --> databaseIO.ts @@ -23,14 +23,14 @@ graph TD conflictResolution.ts --> databaseIO.ts ``` -- **`index.ts`**: The entry point that defines the `useHiddenFileSync` service feature, initializing the state and wiring up events and commands. +- **`index.ts`**: The entry point that defines the `useHiddenFileSync` service feature, initialising the state and wiring up events and commands. - **`types.ts`**: Defines the services required from the global `ServiceHub` (`HiddenFileSyncServices`), required `ServiceModules`, and the `HiddenFileSyncHost` interface. - **`state.ts`**: Encapsulates all mutable runtime states (e.g. processor references, semaphores, processed file caches) inside a single state object. - **`stateHelpers.ts`**: Pure functions for manipulating the state, comparing modification times (`mtime`), and converting files to unique keys. - **`databaseIO.ts`**: Side-effect functions for writing/reading database entries and storage files. -- **`syncOperations.ts`**: Core synchronization logic (scanning storage/database, tracking modifications, and applying offline changes). +- **`syncOperations.ts`**: Core synchronisation logic (scanning storage/database, tracking modifications, and applying offline changes). - **`rebuild.ts`**: Database re-initialisation logic (rebuilding storage from database, database from storage, or safe merging). -- **`conflictResolution.ts`**: Logic for managing conflicts on json and binary configuration files, showing merge dialogs, and resolving conflicts. +- **`conflictResolution.ts`**: Logic for managing conflicts on JSON and binary configuration files, showing merge dialogues, and resolving conflicts. - **`startupScan.ts`**: Logic for checking module status and triggering the initial scan during startup. - **`eventBindings.ts`**: Registers event handlers to Obsidian and other internal services. - **`commands.ts`**: Registers ribbon commands and Command Palette commands. @@ -38,15 +38,18 @@ graph TD ## Key Workflows ### Storage to Database Sync (`scanAllStorageChanges`) + 1. Enumerates all target hidden files in the vault. 2. Compares the current file metadata (`stat`) with the cached metadata in `state._fileInfoLastProcessed`. 3. If changes are detected, stores the updated file content in the database using chunk-based replication. ### Database to Storage Sync (`scanAllDatabaseChanges`) + 1. Queries all documents in the database matching the hidden file prefix. 2. Compares the database metadata with the cached metadata in `state._databaseInfoLastProcessed`. 3. If the database entry is newer, extracts and writes the file back to the storage (and schedules Obsidian reload if configs changed). ### Merging and Conflict Resolution + - Conflict checks are enqueued into a sequential processor (`conflictResolutionProcessor`). - If a JSON file is conflicted, a 3-way merge is attempted. If it fails, or if it is a non-JSON file, the conflict is resolved by keeping the newer modification time (`resolveByNewerEntry`) or prompting the user (`JsonResolveModal`). diff --git a/src/serviceFeatures/hiddenFileSync/syncOperations.ts b/src/serviceFeatures/hiddenFileSync/syncOperations.ts index c90ebac..5743178 100644 --- a/src/serviceFeatures/hiddenFileSync/syncOperations.ts +++ b/src/serviceFeatures/hiddenFileSync/syncOperations.ts @@ -260,7 +260,7 @@ export async function getFiles( ): Promise { let w: any; try { - w = await (host as any).app.vault.adapter.list(path); + w = await host.context.app.vault.adapter.list(path); } catch (ex) { console.warn(`Could not traverse(HiddenSync):${path}`, ex); return []; @@ -289,7 +289,7 @@ export async function getFiles( * @returns A list of hidden file paths. */ export async function scanInternalFileNames(host: HiddenFileSyncHost, state: HiddenFileSyncState): Promise { - const root = (host as any).app.vault.getRoot(); + const root = host.context.app.vault.getRoot(); const findRoot = root.path; const filenames = await getFiles(host, state, findRoot, (path) => isTargetFile(host, () => {}, state, path)); return filenames as FilePath[]; @@ -867,8 +867,8 @@ export function notifyConfigChange(host: HiddenFileSyncHost, state: HiddenFileSy const updatedFolders = [...state.queuedNotificationFiles]; state.queuedNotificationFiles.clear(); try { - const manifests = Object.values((host as any).app.plugins.manifests) as unknown as PluginManifest[]; - const enabledPlugins = (host as any).app.plugins.enabledPlugins as Set; + const manifests = Object.values((host.context.app as any).plugins.manifests) as unknown as PluginManifest[]; + const enabledPlugins = (host.context.app as any).plugins.enabledPlugins as Set; const enabledPluginManifests = manifests.filter((e) => enabledPlugins.has(e.id)); const modifiedManifests = enabledPluginManifests.filter((e) => updatedFolders.indexOf(e?.dir ?? "") >= 0); for (const manifest of modifiedManifests) { @@ -882,8 +882,8 @@ export function notifyConfigChange(host: HiddenFileSyncHost, state: HiddenFileSy anchor.addEventListener("click", () => { fireAndForget(async () => { console.log(`Unloading plugin: ${updatePluginName}`); - await (host as any).app.plugins.unloadPlugin(updatePluginId); - await (host as any).app.plugins.loadPlugin(updatePluginId); + await (host.context.app as any).plugins.unloadPlugin(updatePluginId); + await (host.context.app as any).plugins.loadPlugin(updatePluginId); console.log(`Plugin reloaded: ${updatePluginName}`); }); }); diff --git a/src/serviceFeatures/interactiveConflictResolver/conflictOperations.ts b/src/serviceFeatures/interactiveConflictResolver/conflictOperations.ts index 3167ae6..d0e728d 100644 --- a/src/serviceFeatures/interactiveConflictResolver/conflictOperations.ts +++ b/src/serviceFeatures/interactiveConflictResolver/conflictOperations.ts @@ -32,7 +32,7 @@ export async function resolveConflictByUI( filename: FilePathWithPrefix, conflictCheckResult: diff_result ): Promise { - const app = (host as any).app; + const app = host.context.app; if (!app) { log(`Merge: App instance not available`, LOG_LEVEL_VERBOSE); return false; diff --git a/src/serviceFeatures/interactiveConflictResolver/index.ts b/src/serviceFeatures/interactiveConflictResolver/index.ts index e1eac9d..af368b4 100644 --- a/src/serviceFeatures/interactiveConflictResolver/index.ts +++ b/src/serviceFeatures/interactiveConflictResolver/index.ts @@ -1,4 +1,4 @@ -import { createServiceFeature } from "@lib/interfaces/ServiceModule.ts"; +import { createObsidianServiceFeature } from "@/types.ts"; import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts"; import type { ConflictResolverServices, ConflictResolverModules } from "./types.ts"; import { resolveConflictByUI, allConflictCheck, pickFileForResolve, allScanStat } from "./conflictOperations.ts"; @@ -7,9 +7,10 @@ import { resolveConflictByUI, allConflictCheck, pickFileForResolve, allScanStat * A service feature hook that initialises and manages the Interactive Conflict Resolver. * Registers conflict resolution commands and handles user-interactive resolution flows. */ -export const useInteractiveConflictResolver = createServiceFeature< +export const useInteractiveConflictResolver = createObsidianServiceFeature< ConflictResolverServices, ConflictResolverModules, + "app", void >((host) => { const log = createInstanceLogFunction("InteractiveConflictResolver", host.services.API); diff --git a/src/serviceFeatures/interactiveConflictResolver/interactiveConflictResolver.unit.spec.ts b/src/serviceFeatures/interactiveConflictResolver/interactiveConflictResolver.unit.spec.ts index b159df8..6837a8d 100644 --- a/src/serviceFeatures/interactiveConflictResolver/interactiveConflictResolver.unit.spec.ts +++ b/src/serviceFeatures/interactiveConflictResolver/interactiveConflictResolver.unit.spec.ts @@ -52,7 +52,9 @@ describe("InteractiveConflictResolver Operations", () => { vi.clearAllMocks(); host = { - app: {} as any, + context: { + app: {} as any, + }, services: { API: { confirm: { diff --git a/src/serviceFeatures/interactiveConflictResolver/types.ts b/src/serviceFeatures/interactiveConflictResolver/types.ts index fa8ea83..be827df 100644 --- a/src/serviceFeatures/interactiveConflictResolver/types.ts +++ b/src/serviceFeatures/interactiveConflictResolver/types.ts @@ -1,4 +1,4 @@ -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * A union of service keys required by the interactive conflict resolver feature. @@ -21,4 +21,4 @@ export type ConflictResolverModules = "databaseFileAccess"; /** * The host type representing the injected service container with conflict resolution capabilities. */ -export type ConflictResolverHost = NecessaryServices; +export type ConflictResolverHost = NecessaryObsidianServices; diff --git a/src/serviceFeatures/logFeature/index.ts b/src/serviceFeatures/logFeature/index.ts index 09cdc79..4c54e6e 100644 --- a/src/serviceFeatures/logFeature/index.ts +++ b/src/serviceFeatures/logFeature/index.ts @@ -1,4 +1,4 @@ -import { createServiceFeature } from "@lib/interfaces/ServiceModule.ts"; +import { createObsidianServiceFeature } from "@/types.ts"; import { setGlobalLogFunction } from "octagonal-wheels/common/logger"; import { LiveSyncError } from "@lib/common/LSError.ts"; import type { LogEntry } from "@lib/mock_and_interop/stores.ts"; @@ -49,7 +49,12 @@ setGlobalLogFunction(globalLogFunction); /** * A service feature hook that initialises and manages logging, status display, and debug report generation. */ -export const useLogFeature = createServiceFeature((host) => { +export const useLogFeature = createObsidianServiceFeature< + LogFeatureServices, + LogFeatureModules, + "app" | "liveSyncPlugin", + void +>((host) => { const state = createInitialState(); activeState = state; @@ -93,7 +98,7 @@ export const useLogFeature = createServiceFeature new LogPaneView(leaf, plugin)); return Promise.resolve(true); }; @@ -116,7 +121,7 @@ export const useLogFeature = createServiceFeature { - const app = (host as any).app; + const app = host.context.app; const reason = [] as string[]; const reasonWarn = [] as string[]; const thisFile = app.workspace.getActiveFile(); diff --git a/src/serviceFeatures/logFeature/types.ts b/src/serviceFeatures/logFeature/types.ts index ce40670..c7aed8f 100644 --- a/src/serviceFeatures/logFeature/types.ts +++ b/src/serviceFeatures/logFeature/types.ts @@ -1,4 +1,4 @@ -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * Service keys required by the logging and status bar feature. @@ -22,4 +22,4 @@ export type LogFeatureModules = "storageAccess"; /** * The host type representing the injected service container with logging capabilities. */ -export type LogFeatureHost = NecessaryServices; +export type LogFeatureHost = NecessaryObsidianServices; diff --git a/src/serviceFeatures/migration/index.ts b/src/serviceFeatures/migration/index.ts index 4fad417..ebd2126 100644 --- a/src/serviceFeatures/migration/index.ts +++ b/src/serviceFeatures/migration/index.ts @@ -1,337 +1,14 @@ -import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "@lib/common/logger.ts"; -import { - EVENT_REQUEST_OPEN_P2P, - EVENT_REQUEST_OPEN_SETTING_WIZARD, - EVENT_REQUEST_OPEN_SETTINGS, - EVENT_REQUEST_RUN_DOCTOR, - EVENT_REQUEST_RUN_FIX_INCOMPLETE, - eventHub, -} from "@/common/events.ts"; -import { $msg } from "@lib/common/i18n.ts"; -import { performDoctorConsultation, RebuildOptions } from "@lib/common/configForDoc.ts"; -import { isValidPath } from "@/common/utils.ts"; -import { isMetaEntry } from "@lib/common/types.ts"; -import { isDeletedEntry, isDocContentSame, isLoadedEntry, readAsBlob } from "@lib/common/utils.ts"; -import { countCompromisedChunks } from "@lib/pouchdb/negotiation.ts"; import { createObsidianServiceFeature } from "@/types.ts"; -import { getSetupManager } from "@/serviceFeatures/setupManager/index.ts"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts"; -import { type LogFunction } from "@lib/services/lib/logUtils.ts"; +import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils.ts"; -type ErrorInfo = { - path: string; - recordedSize: number; - actualSize: number; - storageSize: number; - contentMatched: boolean; - isConflicted?: boolean; -}; +import { bindMigrationRequestEvents, runFirstInitialiseMigration } from "./migrationOperations.ts"; +import type { MigrationModules, MigrationServices } from "./types.ts"; -export const useMigrationFeature = createObsidianServiceFeature< - "API" | "appLifecycle" | "setting" | "database" | "path" | "vault" | "replicator" | "UI" | "keyValueDB", - "storageAccess" | "fileHandler" | "rebuilder" ->((host) => { - const services = host.services; - const serviceModules = host.serviceModules; - const log: LogFunction = createInstanceLogFunction("Migration", services.API); +export const useMigrationFeature = createObsidianServiceFeature((host) => { + const log: LogFunction = createInstanceLogFunction("Migration", host.services.API); - const migrateUsingDoctor = async ( - skipRebuild: boolean = false, - activateReason = "updated", - forceRescan = false - ) => { - const env = { confirm: services.UI.confirm }; - const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation( - env, - services.setting.settings, - { - localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable, - remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable, - activateReason, - forceRescan, - } - ); - if (isModified) { - await services.setting.applyExternalSettings(settings, true); - } - if (!skipRebuild) { - if (shouldRebuild) { - await serviceModules.rebuilder.scheduleRebuild(); - services.appLifecycle.performRestart(); - return false; - } else if (shouldRebuildLocal) { - await serviceModules.rebuilder.scheduleFetch(); - services.appLifecycle.performRestart(); - return false; - } - } - return true; - }; - - const migrateDisableBulkSend = async () => { - if (services.setting.settings.sendChunksBulk) { - log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE); - await services.setting.applyExternalSettings( - { - ...services.setting.settings, - sendChunksBulk: false, - sendChunksBulkMaxSize: 1, - }, - true - ); - } - }; - - const initialMessage = async () => { - return await getSetupManager().startOnBoarding(); - }; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const askAgainForSetupURI = async () => { - const message = $msg("moduleMigration.msgRecommendSetupUri", { URI_DOC: $msg("moduleMigration.docUri") }); - const USE_MINIMAL = $msg("moduleMigration.optionSetupWizard"); - const USE_P2P = $msg("moduleMigration.optionSetupViaP2P"); - const USE_SETUP = $msg("moduleMigration.optionManualSetup"); - const NEXT = $msg("moduleMigration.optionRemindNextLaunch"); - - const ret = await services.UI.confirm.askSelectStringDialogue( - message, - [USE_MINIMAL, USE_SETUP, USE_P2P, NEXT], - { - title: $msg("moduleMigration.titleRecommendSetupUri"), - defaultAction: USE_MINIMAL, - } - ); - if (ret === USE_MINIMAL) { - eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD); - return false; - } - if (ret === USE_P2P) { - eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P); - return false; - } - if (ret === USE_SETUP) { - eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTINGS); - return false; - } else if (ret == NEXT) { - return false; - } - return false; - }; - - const hasIncompleteDocs = async (force: boolean = false): Promise => { - const kvDB = services.keyValueDB.kvDB; - const incompleteDocsChecked = (await kvDB.get("checkIncompleteDocs")) || false; - if (incompleteDocsChecked && !force) { - log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE); - return Promise.resolve(true); - } - - log("Checking for incomplete documents...", LOG_LEVEL_NOTICE, "check-incomplete"); - - const errorFiles = [] as ErrorInfo[]; - for await (const metaDoc of services.database.localDatabase.findAllNormalDocs({ conflicts: true })) { - const path = services.path.getPath(metaDoc); - - if (!isValidPath(path)) { - continue; - } - if (!(await services.vault.isTargetFile(path))) { - continue; - } - if (!isMetaEntry(metaDoc)) { - continue; - } - - const doc = await services.database.localDatabase.getDBEntryFromMeta(metaDoc); - if (!doc || !isLoadedEntry(doc)) { - continue; - } - if (isDeletedEntry(doc)) { - continue; - } - const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0; - - let storageFileContent; - try { - storageFileContent = await serviceModules.storageAccess.readHiddenFileBinary(path); - } catch (e) { - Logger(`Failed to read file ${path}: Possibly unprocessed or missing`); - Logger(e, LOG_LEVEL_VERBOSE); - continue; - } - const sizeOnStorage = storageFileContent.byteLength; - const recordedSize = doc.size; - const docBlob = readAsBlob(doc); - const actualSize = docBlob.size; - if ( - recordedSize !== actualSize || - sizeOnStorage !== actualSize || - sizeOnStorage !== recordedSize || - isConflicted - ) { - const contentMatched = await isDocContentSame(doc.data, storageFileContent); - errorFiles.push({ - path, - recordedSize, - actualSize, - storageSize: sizeOnStorage, - contentMatched, - isConflicted, - }); - Logger( - `Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}` - ); - } - } - if (errorFiles.length == 0) { - Logger("No size mismatches found", LOG_LEVEL_NOTICE); - await kvDB.set("checkIncompleteDocs", true); - return Promise.resolve(true); - } - Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_NOTICE); - const recoverable = errorFiles.filter((e) => { - return e.recordedSize === e.storageSize && !e.isConflicted; - }); - const unrecoverable = errorFiles.filter((e) => { - return e.recordedSize !== e.storageSize || e.isConflicted; - }); - const fileInfo = (e: (typeof errorFiles)[0]) => { - return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`; - }; - const messageUnrecoverable = - unrecoverable.length > 0 - ? $msg("moduleMigration.fix0256.messageUnrecoverable", { - filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"), - }) - : ""; - - const message = $msg("moduleMigration.fix0256.message", { - files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"), - messageUnrecoverable, - }); - const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater"); - const FIX = $msg("moduleMigration.fix0256.buttons.fix"); - const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever"); - const ret = await services.UI.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], { - title: $msg("moduleMigration.fix0256.title"), - defaultAction: CHECK_IT_LATER, - }); - if (ret == FIX) { - for (const file of recoverable) { - const stubFile = await serviceModules.storageAccess.getFileStub(file.path); - if (stubFile == null) { - Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE); - continue; - } - - stubFile.stat.mtime = Date.now(); - const result = await serviceModules.fileHandler.storeFileToDB(stubFile, true, false); - if (result) { - Logger(`Successfully restored ${file.path} from storage`); - } else { - Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE); - } - } - } else if (ret === DISMISS) { - await kvDB.set("checkIncompleteDocs", true); - } - - return Promise.resolve(true); - }; - - const hasCompromisedChunks = async (): Promise => { - Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE); - if (!services.setting.settings.encrypt) { - return true; - } - const localCompromised = await countCompromisedChunks(services.database.localDatabase.localDatabase); - const remote = services.replicator.getActiveReplicator(); - const remoteCompromised = services.API.isOnline ? await remote?.countCompromisedChunks() : 0; - if (localCompromised === false) { - Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE); - return false; - } - if (remoteCompromised === false) { - Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE); - return false; - } - if (remoteCompromised === 0 && localCompromised === 0) { - return true; - } - Logger( - `Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`, - LOG_LEVEL_NOTICE - ); - const title = $msg("moduleMigration.insecureChunkExist.title"); - const msg = $msg("moduleMigration.insecureChunkExist.message"); - const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild"); - const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch"); - const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later"); - const buttons = [REBUILD, FETCH, DISMISS]; - if (remoteCompromised != 0) { - buttons.splice(buttons.indexOf(FETCH), 1); - } - const result = await services.UI.confirm.askSelectStringDialogue(msg, buttons, { - title, - defaultAction: DISMISS, - timeout: 0, - }); - if (result === REBUILD) { - await serviceModules.rebuilder.scheduleRebuild(); - services.appLifecycle.performRestart(); - return false; - } else if (result === FETCH) { - await serviceModules.rebuilder.scheduleFetch(); - services.appLifecycle.performRestart(); - return false; - } else { - log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE); - } - return true; - }; - - const everyOnFirstInitialize = async (): Promise => { - if (!services.database.localDatabase.isReady) { - log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE); - return false; - } - if (services.setting.settings.isConfigured) { - if (!(await hasCompromisedChunks())) { - return false; - } - if (!(await hasIncompleteDocs())) { - return false; - } - if (!(await migrateUsingDoctor(false))) { - return false; - } - await migrateDisableBulkSend(); - } - if (!services.setting.settings.isConfigured) { - if (!(await initialMessage())) { - log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE); - return false; - } - if (!(await migrateUsingDoctor(true))) { - return false; - } - } - return true; - }; - - const everyOnLayoutReady = async (): Promise => { - eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => { - await migrateUsingDoctor(false, reason, true); - }); - eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => { - await hasIncompleteDocs(true); - }); - return Promise.resolve(true); - }; - - services.appLifecycle.onLayoutReady.addHandler(everyOnLayoutReady); - services.appLifecycle.onFirstInitialise.addHandler(everyOnFirstInitialize); + host.services.appLifecycle.onLayoutReady.addHandler(() => bindMigrationRequestEvents(host, log)); + host.services.appLifecycle.onFirstInitialise.addHandler(() => runFirstInitialiseMigration(host, log)); return {}; }); diff --git a/src/serviceFeatures/migration/migrationOperations.ts b/src/serviceFeatures/migration/migrationOperations.ts new file mode 100644 index 0000000..3288a13 --- /dev/null +++ b/src/serviceFeatures/migration/migrationOperations.ts @@ -0,0 +1,331 @@ +import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@lib/common/logger.ts"; +import { + EVENT_REQUEST_OPEN_P2P, + EVENT_REQUEST_OPEN_SETTING_WIZARD, + EVENT_REQUEST_OPEN_SETTINGS, + EVENT_REQUEST_RUN_DOCTOR, + EVENT_REQUEST_RUN_FIX_INCOMPLETE, + eventHub, +} from "@/common/events.ts"; +import { $msg } from "@lib/common/i18n.ts"; +import { performDoctorConsultation, RebuildOptions } from "@lib/common/configForDoc.ts"; +import { isValidPath } from "@/common/utils.ts"; +import { isMetaEntry } from "@lib/common/types.ts"; +import { isDeletedEntry, isDocContentSame, isLoadedEntry, readAsBlob } from "@lib/common/utils.ts"; +import { countCompromisedChunks } from "@lib/pouchdb/negotiation.ts"; +import { getSetupManager } from "@/serviceFeatures/setupManager/index.ts"; +import type { LogFunction } from "@lib/services/lib/logUtils.ts"; + +import type { MigrationHost } from "./types.ts"; + +type ErrorInfo = { + path: string; + recordedSize: number; + actualSize: number; + storageSize: number; + contentMatched: boolean; + isConflicted?: boolean; +}; + +export async function migrateUsingDoctor( + host: MigrationHost, + skipRebuild: boolean = false, + activateReason = "updated", + forceRescan = false +): Promise { + const services = host.services; + const env = { confirm: services.UI.confirm }; + const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation( + env, + services.setting.settings, + { + localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable, + remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable, + activateReason, + forceRescan, + } + ); + if (isModified) { + await services.setting.applyExternalSettings(settings, true); + } + if (!skipRebuild) { + if (shouldRebuild) { + await host.serviceModules.rebuilder.scheduleRebuild(); + services.appLifecycle.performRestart(); + return false; + } else if (shouldRebuildLocal) { + await host.serviceModules.rebuilder.scheduleFetch(); + services.appLifecycle.performRestart(); + return false; + } + } + return true; +} + +export async function migrateDisableBulkSend(host: MigrationHost, log: LogFunction): Promise { + const services = host.services; + if (services.setting.settings.sendChunksBulk) { + log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE); + await services.setting.applyExternalSettings( + { + ...services.setting.settings, + sendChunksBulk: false, + sendChunksBulkMaxSize: 1, + }, + true + ); + } +} + +export async function initialMigrationMessage(): Promise { + return await getSetupManager().startOnBoarding(); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function askAgainForSetupURI(host: MigrationHost): Promise { + const message = $msg("moduleMigration.msgRecommendSetupUri", { URI_DOC: $msg("moduleMigration.docUri") }); + const USE_MINIMAL = $msg("moduleMigration.optionSetupWizard"); + const USE_P2P = $msg("moduleMigration.optionSetupViaP2P"); + const USE_SETUP = $msg("moduleMigration.optionManualSetup"); + const NEXT = $msg("moduleMigration.optionRemindNextLaunch"); + + const ret = await host.services.UI.confirm.askSelectStringDialogue( + message, + [USE_MINIMAL, USE_SETUP, USE_P2P, NEXT], + { + title: $msg("moduleMigration.titleRecommendSetupUri"), + defaultAction: USE_MINIMAL, + } + ); + if (ret === USE_MINIMAL) { + eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD); + return false; + } + if (ret === USE_P2P) { + eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P); + return false; + } + if (ret === USE_SETUP) { + eventHub.emitEvent(EVENT_REQUEST_OPEN_SETTINGS); + return false; + } else if (ret == NEXT) { + return false; + } + return false; +} + +export async function hasIncompleteDocs( + host: MigrationHost, + log: LogFunction, + force: boolean = false +): Promise { + const services = host.services; + const serviceModules = host.serviceModules; + const kvDB = services.keyValueDB.kvDB; + const incompleteDocsChecked = (await kvDB.get("checkIncompleteDocs")) || false; + if (incompleteDocsChecked && !force) { + log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE); + return Promise.resolve(true); + } + + log("Checking for incomplete documents...", LOG_LEVEL_NOTICE, "check-incomplete"); + + const errorFiles = [] as ErrorInfo[]; + for await (const metaDoc of services.database.localDatabase.findAllNormalDocs({ conflicts: true })) { + const path = services.path.getPath(metaDoc); + + if (!isValidPath(path)) { + continue; + } + if (!(await services.vault.isTargetFile(path))) { + continue; + } + if (!isMetaEntry(metaDoc)) { + continue; + } + + const doc = await services.database.localDatabase.getDBEntryFromMeta(metaDoc); + if (!doc || !isLoadedEntry(doc)) { + continue; + } + if (isDeletedEntry(doc)) { + continue; + } + const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0; + + let storageFileContent; + try { + storageFileContent = await serviceModules.storageAccess.readHiddenFileBinary(path); + } catch (e) { + log(`Failed to read file ${path}: Possibly unprocessed or missing`); + log(e, LOG_LEVEL_VERBOSE); + continue; + } + const sizeOnStorage = storageFileContent.byteLength; + const recordedSize = doc.size; + const docBlob = readAsBlob(doc); + const actualSize = docBlob.size; + if ( + recordedSize !== actualSize || + sizeOnStorage !== actualSize || + sizeOnStorage !== recordedSize || + isConflicted + ) { + const contentMatched = await isDocContentSame(doc.data, storageFileContent); + errorFiles.push({ + path, + recordedSize, + actualSize, + storageSize: sizeOnStorage, + contentMatched, + isConflicted, + }); + log( + `Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}` + ); + } + } + if (errorFiles.length == 0) { + log("No size mismatches found", LOG_LEVEL_NOTICE); + await kvDB.set("checkIncompleteDocs", true); + return Promise.resolve(true); + } + log(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_NOTICE); + const recoverable = errorFiles.filter((e) => { + return e.recordedSize === e.storageSize && !e.isConflicted; + }); + const unrecoverable = errorFiles.filter((e) => { + return e.recordedSize !== e.storageSize || e.isConflicted; + }); + const fileInfo = (e: (typeof errorFiles)[0]) => { + return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`; + }; + const messageUnrecoverable = + unrecoverable.length > 0 + ? $msg("moduleMigration.fix0256.messageUnrecoverable", { + filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"), + }) + : ""; + + const message = $msg("moduleMigration.fix0256.message", { + files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"), + messageUnrecoverable, + }); + const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater"); + const FIX = $msg("moduleMigration.fix0256.buttons.fix"); + const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever"); + const ret = await services.UI.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], { + title: $msg("moduleMigration.fix0256.title"), + defaultAction: CHECK_IT_LATER, + }); + if (ret == FIX) { + for (const file of recoverable) { + const stubFile = await serviceModules.storageAccess.getFileStub(file.path); + if (stubFile == null) { + log(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE); + continue; + } + + stubFile.stat.mtime = Date.now(); + const result = await serviceModules.fileHandler.storeFileToDB(stubFile, true, false); + if (result) { + log(`Successfully restored ${file.path} from storage`); + } else { + log(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE); + } + } + } else if (ret === DISMISS) { + await kvDB.set("checkIncompleteDocs", true); + } + + return Promise.resolve(true); +} + +export async function hasCompromisedChunks(host: MigrationHost, log: LogFunction): Promise { + const services = host.services; + log(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE); + if (!services.setting.settings.encrypt) { + return true; + } + const localCompromised = await countCompromisedChunks(services.database.localDatabase.localDatabase); + const remote = services.replicator.getActiveReplicator(); + const remoteCompromised = services.API.isOnline ? await remote?.countCompromisedChunks() : 0; + if (localCompromised === false) { + log(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE); + return false; + } + if (remoteCompromised === false) { + log(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE); + return false; + } + if (remoteCompromised === 0 && localCompromised === 0) { + return true; + } + log(`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`, LOG_LEVEL_NOTICE); + const title = $msg("moduleMigration.insecureChunkExist.title"); + const msg = $msg("moduleMigration.insecureChunkExist.message"); + const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild"); + const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch"); + const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later"); + const buttons = [REBUILD, FETCH, DISMISS]; + if (remoteCompromised != 0) { + buttons.splice(buttons.indexOf(FETCH), 1); + } + const result = await services.UI.confirm.askSelectStringDialogue(msg, buttons, { + title, + defaultAction: DISMISS, + timeout: 0, + }); + if (result === REBUILD) { + await host.serviceModules.rebuilder.scheduleRebuild(); + services.appLifecycle.performRestart(); + return false; + } else if (result === FETCH) { + await host.serviceModules.rebuilder.scheduleFetch(); + services.appLifecycle.performRestart(); + return false; + } else { + log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE); + } + return true; +} + +export async function runFirstInitialiseMigration(host: MigrationHost, log: LogFunction): Promise { + const services = host.services; + if (!services.database.localDatabase.isReady) { + log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE); + return false; + } + if (services.setting.settings.isConfigured) { + if (!(await hasCompromisedChunks(host, log))) { + return false; + } + if (!(await hasIncompleteDocs(host, log))) { + return false; + } + if (!(await migrateUsingDoctor(host, false))) { + return false; + } + await migrateDisableBulkSend(host, log); + } + if (!services.setting.settings.isConfigured) { + if (!(await initialMigrationMessage())) { + log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE); + return false; + } + if (!(await migrateUsingDoctor(host, true))) { + return false; + } + } + return true; +} + +export function bindMigrationRequestEvents(host: MigrationHost, log: LogFunction): Promise { + eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => { + await migrateUsingDoctor(host, false, reason, true); + }); + eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => { + await hasIncompleteDocs(host, log, true); + }); + return Promise.resolve(true); +} diff --git a/src/serviceFeatures/migration/migrationOperations.unit.spec.ts b/src/serviceFeatures/migration/migrationOperations.unit.spec.ts new file mode 100644 index 0000000..ec6eb42 --- /dev/null +++ b/src/serviceFeatures/migration/migrationOperations.unit.spec.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + performDoctorConsultation: vi.fn(), + countCompromisedChunks: vi.fn(), + startOnBoarding: vi.fn(), + onEvent: vi.fn(), + emitEvent: vi.fn(), +})); + +vi.mock("@lib/common/logger.ts", () => ({ + LOG_LEVEL_NOTICE: 1, + LOG_LEVEL_VERBOSE: 16, +})); + +vi.mock("@/common/events.ts", () => ({ + EVENT_REQUEST_OPEN_P2P: "request-open-p2p", + EVENT_REQUEST_OPEN_SETTING_WIZARD: "request-open-setting-wizard", + EVENT_REQUEST_OPEN_SETTINGS: "request-open-settings", + EVENT_REQUEST_RUN_DOCTOR: "request-run-doctor", + EVENT_REQUEST_RUN_FIX_INCOMPLETE: "request-run-fix-incomplete", + eventHub: { + onEvent: mocks.onEvent, + emitEvent: mocks.emitEvent, + }, +})); + +vi.mock("@lib/common/i18n.ts", () => ({ + $msg: (key: string) => key, +})); + +vi.mock("@lib/common/configForDoc.ts", () => ({ + RebuildOptions: { + AutomaticAcceptable: 0, + SkipEvenIfRequired: 1, + }, + performDoctorConsultation: mocks.performDoctorConsultation, +})); + +vi.mock("@lib/pouchdb/negotiation.ts", () => ({ + countCompromisedChunks: mocks.countCompromisedChunks, +})); + +vi.mock("@/common/utils.ts", () => ({ + isValidPath: vi.fn(() => true), +})); + +vi.mock("@lib/common/types.ts", () => ({ + isMetaEntry: vi.fn(() => true), +})); + +vi.mock("@lib/common/utils.ts", () => ({ + isDeletedEntry: vi.fn(() => false), + isDocContentSame: vi.fn(async () => true), + isLoadedEntry: vi.fn(() => true), + readAsBlob: vi.fn((doc: any) => ({ size: doc.size })), +})); + +vi.mock("@/serviceFeatures/setupManager/index.ts", () => ({ + getSetupManager: () => ({ + startOnBoarding: mocks.startOnBoarding, + }), +})); + +import { + hasCompromisedChunks, + initialMigrationMessage, + migrateDisableBulkSend, + migrateUsingDoctor, + runFirstInitialiseMigration, +} from "./migrationOperations.ts"; + +function createHost(overrides: any = {}) { + const settings = { + isConfigured: true, + encrypt: false, + sendChunksBulk: false, + sendChunksBulkMaxSize: 100, + ...overrides.settings, + }; + return { + services: { + API: { + isOnline: true, + }, + UI: { + confirm: { + askSelectStringDialogue: vi.fn(), + }, + }, + appLifecycle: { + performRestart: vi.fn(), + }, + setting: { + settings, + applyExternalSettings: vi.fn(async (next: any) => { + Object.assign(settings, next); + }), + }, + database: { + localDatabase: { + isReady: true, + localDatabase: {}, + }, + }, + keyValueDB: { + kvDB: { + get: vi.fn(), + set: vi.fn(), + }, + }, + path: { + getPath: vi.fn((entry: any) => entry.path), + }, + vault: { + isTargetFile: vi.fn(async () => true), + }, + replicator: { + getActiveReplicator: vi.fn(), + }, + ...overrides.services, + }, + serviceModules: { + rebuilder: { + scheduleRebuild: vi.fn(), + scheduleFetch: vi.fn(), + }, + storageAccess: { + readHiddenFileBinary: vi.fn(), + getFileStub: vi.fn(), + }, + fileHandler: { + storeFileToDB: vi.fn(), + }, + ...overrides.serviceModules, + }, + } as any; +} + +describe("migration operations", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.performDoctorConsultation.mockResolvedValue({ + shouldRebuild: false, + shouldRebuildLocal: false, + isModified: false, + settings: {}, + }); + mocks.startOnBoarding.mockResolvedValue(true); + }); + + it("schedules a remote rebuild and restarts when doctor requires it", async () => { + const host = createHost(); + mocks.performDoctorConsultation.mockResolvedValue({ + shouldRebuild: true, + shouldRebuildLocal: false, + isModified: false, + settings: {}, + }); + + const result = await migrateUsingDoctor(host, false); + + expect(result).toBe(false); + expect(host.serviceModules.rebuilder.scheduleRebuild).toHaveBeenCalledTimes(1); + expect(host.services.appLifecycle.performRestart).toHaveBeenCalledTimes(1); + }); + + it("applies modified settings reported by doctor", async () => { + const host = createHost(); + mocks.performDoctorConsultation.mockResolvedValue({ + shouldRebuild: false, + shouldRebuildLocal: false, + isModified: true, + settings: { isConfigured: true, sendChunksBulk: false }, + }); + + await expect(migrateUsingDoctor(host, true)).resolves.toBe(true); + + expect(host.services.setting.applyExternalSettings).toHaveBeenCalledWith( + { isConfigured: true, sendChunksBulk: false }, + true + ); + expect(host.serviceModules.rebuilder.scheduleRebuild).not.toHaveBeenCalled(); + expect(host.services.appLifecycle.performRestart).not.toHaveBeenCalled(); + }); + + it("disables bulk chunk sending when the migration flag is active", async () => { + const host = createHost({ settings: { sendChunksBulk: true, sendChunksBulkMaxSize: 100 } }); + const log = vi.fn(); + + await migrateDisableBulkSend(host, log); + + expect(log).toHaveBeenCalledWith(expect.any(String), 1); + expect(host.services.setting.applyExternalSettings).toHaveBeenCalledWith( + expect.objectContaining({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 }), + true + ); + }); + + it("skips compromised chunk checks when encryption is disabled", async () => { + const host = createHost({ settings: { encrypt: false } }); + + await expect(hasCompromisedChunks(host, vi.fn())).resolves.toBe(true); + + expect(mocks.countCompromisedChunks).not.toHaveBeenCalled(); + }); + + it("stops first initialisation when the local database is not ready", async () => { + const host = createHost({ + services: { + database: { + localDatabase: { + isReady: false, + localDatabase: {}, + }, + }, + }, + }); + const log = vi.fn(); + + await expect(runFirstInitialiseMigration(host, log)).resolves.toBe(false); + + expect(log).toHaveBeenCalledWith(expect.any(String), 1); + expect(mocks.performDoctorConsultation).not.toHaveBeenCalled(); + }); + + it("starts onboarding for an unconfigured vault", async () => { + const host = createHost({ settings: { isConfigured: false } }); + + await expect(runFirstInitialiseMigration(host, vi.fn())).resolves.toBe(true); + + expect(mocks.startOnBoarding).toHaveBeenCalledTimes(1); + expect(mocks.performDoctorConsultation).toHaveBeenCalledTimes(1); + }); + + it("delegates the initial migration message to setup manager", async () => { + await expect(initialMigrationMessage()).resolves.toBe(true); + + expect(mocks.startOnBoarding).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/serviceFeatures/migration/types.ts b/src/serviceFeatures/migration/types.ts new file mode 100644 index 0000000..aeaffb2 --- /dev/null +++ b/src/serviceFeatures/migration/types.ts @@ -0,0 +1,16 @@ +import type { NecessaryObsidianServices } from "@/types.ts"; + +export type MigrationServices = + | "API" + | "appLifecycle" + | "setting" + | "database" + | "path" + | "vault" + | "replicator" + | "UI" + | "keyValueDB"; + +export type MigrationModules = "storageAccess" | "fileHandler" | "rebuilder"; + +export type MigrationHost = NecessaryObsidianServices; diff --git a/src/serviceFeatures/mockServiceHub.ts b/src/serviceFeatures/mockServiceHub.ts index 9180055..ddb0674 100644 --- a/src/serviceFeatures/mockServiceHub.ts +++ b/src/serviceFeatures/mockServiceHub.ts @@ -39,8 +39,21 @@ export const createMockServiceHub = () => { localDatabase: { localDatabase: {}, tryAutoMerge: vi.fn(), + onNewLeaf: vi.fn(), + getDBEntryFromMeta: vi.fn(), + getRaw: vi.fn(), + clearCaches: vi.fn(), }, }, + keyValueDB: { + kvDB: { + get: vi.fn(), + set: vi.fn(), + }, + }, + path: { + getPath: vi.fn((entry) => entry.path ?? entry._id), + }, setting: { settings, currentSettings: vi.fn(() => settings), @@ -51,9 +64,15 @@ export const createMockServiceHub = () => { replicate: createEventMock(), checkConnectionFailure: createEventMock(), parseSynchroniseResult: createEventMock(), + processSynchroniseResult: createEventMock(), + processOptionalSynchroniseResult: createEventMock(), + processVirtualDocument: createEventMock(), onBeforeReplicate: createEventMock(), onReplicationFailed: createEventMock(), replicateByEvent: vi.fn(), + databaseQueueCount: { value: 0 }, + storageApplyingCount: { value: 0 }, + replicationResultCount: { value: 0 }, }, conflict: { queueCheckForIfOpen: createEventMock(), @@ -83,9 +102,13 @@ export const createMockServiceHub = () => { setInterval: vi.fn((fn, interval) => 123), clearInterval: vi.fn(), addCommand: vi.fn(), + addLog: vi.fn(), }, vault: { getActiveFilePath: vi.fn(), + isTargetFile: vi.fn().mockResolvedValue(true), + isFileSizeTooLarge: vi.fn().mockReturnValue(false), + isValidPath: vi.fn().mockReturnValue(true), }, }, }; diff --git a/src/serviceFeatures/obsidianDocumentHistory/historyOperations.ts b/src/serviceFeatures/obsidianDocumentHistory/historyOperations.ts index 1cda677..0c85624 100644 --- a/src/serviceFeatures/obsidianDocumentHistory/historyOperations.ts +++ b/src/serviceFeatures/obsidianDocumentHistory/historyOperations.ts @@ -12,8 +12,8 @@ import type { LogFunction } from "@lib/services/lib/logUtils"; * @param id - Optional CouchDB document identifier. */ export function showHistory(host: DocumentHistoryHost, file: TFile | FilePathWithPrefix, id?: DocumentID): void { - const app = (host as any).app; - const plugin = (host as any).plugin; + const app = host.context.app; + const plugin = host.context.liveSyncPlugin; new DocumentHistoryModal(app, host as any, plugin, file, id).open(); } diff --git a/src/serviceFeatures/obsidianDocumentHistory/index.ts b/src/serviceFeatures/obsidianDocumentHistory/index.ts index e036600..0bc88c9 100644 --- a/src/serviceFeatures/obsidianDocumentHistory/index.ts +++ b/src/serviceFeatures/obsidianDocumentHistory/index.ts @@ -1,4 +1,4 @@ -import { createServiceFeature } from "@lib/interfaces/ServiceModule.ts"; +import { createObsidianServiceFeature } from "@/types.ts"; import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts"; import { eventHub } from "@/common/events.ts"; import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts"; @@ -10,34 +10,37 @@ import { showHistory, fileHistory } from "./historyOperations.ts"; * A service feature hook that initialises and manages Obsidian Document History commands. * Registers ribbon commands and listens to history request events. */ -export const useObsidianDocumentHistory = createServiceFeature( - (host) => { - const log = createInstanceLogFunction("ObsidianDocumentHistory", host.services.API); +export const useObsidianDocumentHistory = createObsidianServiceFeature< + DocumentHistoryServices, + DocumentHistoryModules, + "app" | "liveSyncPlugin", + void +>((host) => { + const log = createInstanceLogFunction("ObsidianDocumentHistory", host.services.API); - const everyOnloadStart = (): Promise => { - host.services.API.addCommand({ - id: "livesync-history", - name: "Show history", - callback: () => { - const file = host.services.vault.getActiveFilePath(); - if (file) showHistory(host, file, undefined); - }, - }); + const everyOnloadStart = (): Promise => { + host.services.API.addCommand({ + id: "livesync-history", + name: "Show history", + callback: () => { + const file = host.services.vault.getActiveFilePath(); + if (file) showHistory(host, file, undefined); + }, + }); - host.services.API.addCommand({ - id: "livesync-filehistory", - name: "Pick a file to show history", - callback: () => { - fireAndForget(async () => await fileHistory(host, log)); - }, - }); + host.services.API.addCommand({ + id: "livesync-filehistory", + name: "Pick a file to show history", + callback: () => { + fireAndForget(async () => await fileHistory(host, log)); + }, + }); - eventHub.onEvent(EVENT_REQUEST_SHOW_HISTORY, ({ file, fileOnDB }: any) => { - showHistory(host, file, fileOnDB._id); - }); - return Promise.resolve(true); - }; + eventHub.onEvent(EVENT_REQUEST_SHOW_HISTORY, ({ file, fileOnDB }: any) => { + showHistory(host, file, fileOnDB._id); + }); + return Promise.resolve(true); + }; - host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart); - } -); + host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart); +}); diff --git a/src/serviceFeatures/obsidianDocumentHistory/obsidianDocumentHistory.unit.spec.ts b/src/serviceFeatures/obsidianDocumentHistory/obsidianDocumentHistory.unit.spec.ts index 7de1f04..02407f2 100644 --- a/src/serviceFeatures/obsidianDocumentHistory/obsidianDocumentHistory.unit.spec.ts +++ b/src/serviceFeatures/obsidianDocumentHistory/obsidianDocumentHistory.unit.spec.ts @@ -31,8 +31,10 @@ describe("ObsidianDocumentHistory Operations", () => { vi.clearAllMocks(); host = { - app: {}, - plugin: {}, + context: { + app: {}, + liveSyncPlugin: {}, + }, services: { database: { localDatabase: { diff --git a/src/serviceFeatures/obsidianDocumentHistory/types.ts b/src/serviceFeatures/obsidianDocumentHistory/types.ts index b683357..3fffb9c 100644 --- a/src/serviceFeatures/obsidianDocumentHistory/types.ts +++ b/src/serviceFeatures/obsidianDocumentHistory/types.ts @@ -1,4 +1,4 @@ -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * Service keys required by the Obsidian document history feature. @@ -13,4 +13,8 @@ export type DocumentHistoryModules = never; /** * The host type representing the injected service container with document history capabilities. */ -export type DocumentHistoryHost = NecessaryServices; +export type DocumentHistoryHost = NecessaryObsidianServices< + DocumentHistoryServices, + DocumentHistoryModules, + "app" | "liveSyncPlugin" +>; diff --git a/src/serviceFeatures/obsidianEvents/README.md b/src/serviceFeatures/obsidianEvents/README.md index 49f4b64..b7dea5a 100644 --- a/src/serviceFeatures/obsidianEvents/README.md +++ b/src/serviceFeatures/obsidianEvents/README.md @@ -1,6 +1,6 @@ # Obsidian Events (`obsidianEvents`) -This module manages Obsidian-specific application event bindings, editor save command hijacking, window focus/visibility state transitions, and graceful application reload scheduling. It refactors the monolithic implementation of `ModuleObsidianEvents.ts` into a set of decoupled, side-effect-free, and highly testable functions. +This module manages Obsidian-specific application event bindings, editor save command hijacking, window focus/visibility state transitions, and graceful application reload scheduling. It refactors the monolithic implementation of `ModuleObsidianEvents.ts` into a set of decoupled, dependency-explicit, and highly testable functions. ## Module Structure @@ -25,16 +25,19 @@ graph TD ## Key Workflows ### Editor Save Hooking + 1. Intercepts the standard `editor:save-file` command callback. 2. If `syncOnEditorSave` is enabled, schedules a deferred task to execute `replicateByEvent()`. 3. Calls the original save callback to ensure default file writing behaviour continues. ### Suspend & Resume on Window Visibility + 1. Monitors window focus and DOM visibility changes (`visibilitychange`). 2. If the window is hidden and background replication is disabled, invokes `appLifecycle.onSuspending()` to pause active replication feeds. 3. Once the window becomes visible and gains focus, dispatches `onResuming()` and `onResumed()` to re-establish replication channels. ### Stabilised Application Reload + 1. Instead of reloading the plug-in immediately, monitors active processing counters. 2. Combines queue counts for DB transactions, remote replications, chunk transfers, and conflict resolution processors. 3. Triggers the reload once the combined active task count stabilises at 0 for a given timeout, preventing database corruption. diff --git a/src/serviceFeatures/obsidianEvents/appReload.ts b/src/serviceFeatures/obsidianEvents/appReload.ts index 4b2d4d7..7a81c63 100644 --- a/src/serviceFeatures/obsidianEvents/appReload.ts +++ b/src/serviceFeatures/obsidianEvents/appReload.ts @@ -85,7 +85,7 @@ export function scheduleAppReload(host: ObsidianEventsHost, log: LogFunction, st ); }); - const plugin = (host as any).plugin; + const plugin = host.context.plugin; const intervalId = compatGlobal.setInterval(() => { tick.value++; }, 1000); diff --git a/src/serviceFeatures/obsidianEvents/eventBindings.ts b/src/serviceFeatures/obsidianEvents/eventBindings.ts new file mode 100644 index 0000000..40e18c3 --- /dev/null +++ b/src/serviceFeatures/obsidianEvents/eventBindings.ts @@ -0,0 +1,75 @@ +import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "@/common/events.ts"; +import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; +import type { FilePathWithPrefix } from "@lib/common/types.ts"; +import type { LogFunction } from "@lib/services/lib/logUtils.ts"; + +import { askReload, isReloadingScheduled, scheduleAppReload } from "./appReload.ts"; +import { swapSaveCommand } from "./saveCommandHack.ts"; +import type { ObsidianEventsState } from "./state.ts"; +import type { ObsidianEventsHost } from "./types.ts"; +import { setHasFocus, watchOnline, watchWindowVisibility, watchWorkspaceOpen } from "./windowVisibility.ts"; + +export function registerVaultAndWorkspaceEvents(host: ObsidianEventsHost): Promise { + const plugin = host.context.plugin; + const app = host.context.app; + if (plugin && app) { + plugin.registerEvent( + app.vault.on("rename", (file: any, oldPath: string) => { + eventHub.emitEvent(EVENT_FILE_RENAMED, { + newPath: file.path as FilePathWithPrefix, + old: oldPath as FilePathWithPrefix, + }); + }) + ); + plugin.registerEvent( + app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED)) + ); + } + return Promise.resolve(true); +} + +export function registerWindowWatchEvents( + host: ObsidianEventsHost, + log: LogFunction, + state: ObsidianEventsState +): void { + const plugin = host.context.plugin; + const app = host.context.app; + if (plugin && app) { + const currentDoc = typeof activeDocument !== "undefined" ? activeDocument : (compatGlobal as any).document; + + plugin.registerEvent(app.workspace.on("file-open", (file: any) => watchWorkspaceOpen(host, log, file))); + + if (currentDoc) { + plugin.registerDomEvent(currentDoc, "visibilitychange", () => watchWindowVisibility(host, log, state)); + } + + plugin.registerDomEvent(compatGlobal, "focus", () => setHasFocus(host, log, state, true)); + plugin.registerDomEvent(compatGlobal, "blur", () => setHasFocus(host, log, state, false)); + plugin.registerDomEvent(compatGlobal, "online", () => watchOnline(host, log)); + plugin.registerDomEvent(compatGlobal, "offline", () => watchOnline(host, log)); + } +} + +export function onObsidianEventsLayoutReady( + host: ObsidianEventsHost, + log: LogFunction, + state: ObsidianEventsState +): Promise { + swapSaveCommand(host, log, state); + registerWindowWatchEvents(host, log, state); + return Promise.resolve(true); +} + +export function bindObsidianEventsLifecycle( + host: ObsidianEventsHost, + log: LogFunction, + state: ObsidianEventsState +): void { + host.services.appLifecycle.onLayoutReady.addHandler(() => onObsidianEventsLayoutReady(host, log, state)); + host.services.appLifecycle.onInitialise.addHandler(() => registerVaultAndWorkspaceEvents(host)); + + host.services.appLifecycle.askRestart.setHandler((message?: string) => askReload(host, log, message)); + host.services.appLifecycle.scheduleRestart.setHandler(() => scheduleAppReload(host, log, state)); + host.services.appLifecycle.isReloadingScheduled.setHandler(() => isReloadingScheduled(state)); +} diff --git a/src/serviceFeatures/obsidianEvents/index.ts b/src/serviceFeatures/obsidianEvents/index.ts index 2e6370a..b496272 100644 --- a/src/serviceFeatures/obsidianEvents/index.ts +++ b/src/serviceFeatures/obsidianEvents/index.ts @@ -1,70 +1,21 @@ -import { createServiceFeature } from "@lib/interfaces/ServiceModule"; +import { createObsidianServiceFeature } from "@/types.ts"; import { createInstanceLogFunction } from "@lib/services/lib/logUtils.ts"; -import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "@/common/events.ts"; -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; -import type { FilePathWithPrefix } from "@lib/common/types.ts"; import type { ObsidianEventsServices, ObsidianEventsModules } from "./types.ts"; import { createObsidianEventsState } from "./state.ts"; -import { askReload, scheduleAppReload, isReloadingScheduled } from "./appReload.ts"; -import { swapSaveCommand } from "./saveCommandHack.ts"; -import { watchWindowVisibility, watchOnline, watchWorkspaceOpen, setHasFocus } from "./windowVisibility.ts"; +import { bindObsidianEventsLifecycle } from "./eventBindings.ts"; /** * A service feature hook that initialises and manages Obsidian application event bindings. * This hooks into vault file changes, window focus, visibility states, and schedules restarts. */ -export const useObsidianEvents = createServiceFeature((host) => { +export const useObsidianEvents = createObsidianServiceFeature< + ObsidianEventsServices, + ObsidianEventsModules, + "app" | "plugin", + void +>((host) => { const log = createInstanceLogFunction("ObsidianEvents", host.services.API); const state = createObsidianEventsState(); - const plugin = (host as any).plugin; - const app = (host as any).app; - - const everyOnloadStart = (): Promise => { - if (plugin && app) { - plugin.registerEvent( - app.vault.on("rename", (file: any, oldPath: string) => { - eventHub.emitEvent(EVENT_FILE_RENAMED, { - newPath: file.path as FilePathWithPrefix, - old: oldPath as FilePathWithPrefix, - }); - }) - ); - plugin.registerEvent( - app.workspace.on("active-leaf-change", () => eventHub.emitEvent(EVENT_LEAF_ACTIVE_CHANGED)) - ); - } - return Promise.resolve(true); - }; - - const registerWatchEvents = (): void => { - if (plugin && app) { - const currentDoc = typeof activeDocument !== "undefined" ? activeDocument : (compatGlobal as any).document; - - plugin.registerEvent(app.workspace.on("file-open", (file: any) => watchWorkspaceOpen(host, log, file))); - - if (currentDoc) { - plugin.registerDomEvent(currentDoc, "visibilitychange", () => watchWindowVisibility(host, log, state)); - } - - plugin.registerDomEvent(compatGlobal, "focus", () => setHasFocus(host, log, state, true)); - plugin.registerDomEvent(compatGlobal, "blur", () => setHasFocus(host, log, state, false)); - plugin.registerDomEvent(compatGlobal, "online", () => watchOnline(host, log)); - plugin.registerDomEvent(compatGlobal, "offline", () => watchOnline(host, log)); - } - }; - - const everyOnLayoutReady = (): Promise => { - swapSaveCommand(host, log, state); - registerWatchEvents(); - return Promise.resolve(true); - }; - - // Bind event handlers onto the appLifecycle service - host.services.appLifecycle.onLayoutReady.addHandler(everyOnLayoutReady); - host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart); - - (host.services.appLifecycle as any).askRestart.setHandler((message?: string) => askReload(host, log, message)); - (host.services.appLifecycle as any).scheduleRestart.setHandler(() => scheduleAppReload(host, log, state)); - (host.services.appLifecycle as any).isReloadingScheduled.setHandler(() => isReloadingScheduled(state)); + bindObsidianEventsLifecycle(host, log, state); }); diff --git a/src/serviceFeatures/obsidianEvents/obsidianEvents.unit.spec.ts b/src/serviceFeatures/obsidianEvents/obsidianEvents.unit.spec.ts index cbf3cfe..12a1d48 100644 --- a/src/serviceFeatures/obsidianEvents/obsidianEvents.unit.spec.ts +++ b/src/serviceFeatures/obsidianEvents/obsidianEvents.unit.spec.ts @@ -208,8 +208,10 @@ describe("useObsidianEvents Feature Hook", () => { }, appLifecycle, }, - plugin, - app, + context: { + plugin, + app, + }, } as any; useObsidianEvents(host); @@ -356,8 +358,10 @@ describe("scheduleAppReload and isReloadingScheduled", () => { performRestart, }, }, - plugin: { - registerInterval: vi.fn(), + context: { + plugin: { + registerInterval: vi.fn(), + }, }, } as any; @@ -368,7 +372,7 @@ describe("scheduleAppReload and isReloadingScheduled", () => { scheduleAppReload(host, log, state); expect(isReloadingScheduled(state)).toBe(true); - expect(host.plugin.registerInterval).toHaveBeenCalled(); + expect(host.context.plugin.registerInterval).toHaveBeenCalled(); vi.advanceTimersByTime(1000); expect(log).toHaveBeenCalledWith( @@ -415,8 +419,10 @@ describe("scheduleAppReload and isReloadingScheduled", () => { performRestart, }, }, - plugin: { - registerInterval: vi.fn(), + context: { + plugin: { + registerInterval: vi.fn(), + }, }, } as any; @@ -455,12 +461,14 @@ describe("swapSaveCommand", () => { }; const replicateByEvent = vi.fn(async () => {}); const host = { - app: { - commands: { + context: { + app: { commands: { - "editor:save-file": saveCommandDefinition, + commands: { + "editor:save-file": saveCommandDefinition, + }, + executeCommandById: vi.fn(), }, - executeCommandById: vi.fn(), }, }, services: { @@ -499,10 +507,12 @@ describe("swapSaveCommand", () => { callback: originalCallback, }; const host = { - app: { - commands: { + context: { + app: { commands: { - "editor:save-file": saveCommandDefinition, + commands: { + "editor:save-file": saveCommandDefinition, + }, }, }, }, diff --git a/src/serviceFeatures/obsidianEvents/saveCommandHack.ts b/src/serviceFeatures/obsidianEvents/saveCommandHack.ts index a932eb7..2577d67 100644 --- a/src/serviceFeatures/obsidianEvents/saveCommandHack.ts +++ b/src/serviceFeatures/obsidianEvents/saveCommandHack.ts @@ -15,7 +15,7 @@ import type { ObsidianEventsState } from "./state.ts"; */ export function swapSaveCommand(host: ObsidianEventsHost, log: LogFunction, state: ObsidianEventsState): void { log("Modifying callback of the save command", LOG_LEVEL_VERBOSE); - const appAny = (host as any).app; + const appAny = host.context.app as any; const saveCommandDefinition = appAny?.commands?.commands?.["editor:save-file"]; const save = saveCommandDefinition?.callback; if (typeof save === "function") { diff --git a/src/serviceFeatures/obsidianEvents/types.ts b/src/serviceFeatures/obsidianEvents/types.ts index 0285241..de847ef 100644 --- a/src/serviceFeatures/obsidianEvents/types.ts +++ b/src/serviceFeatures/obsidianEvents/types.ts @@ -1,4 +1,4 @@ -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * A union of service keys required by the Obsidian events management feature. @@ -23,4 +23,8 @@ export type ObsidianEventsModules = never; /** * The host type representing the injected service container with Obsidian events capabilities. */ -export type ObsidianEventsHost = NecessaryServices; +export type ObsidianEventsHost = NecessaryObsidianServices< + ObsidianEventsServices, + ObsidianEventsModules, + "app" | "plugin" +>; diff --git a/src/serviceFeatures/obsidianSettingDialogue/index.ts b/src/serviceFeatures/obsidianSettingDialogue/index.ts index 4c7d79f..a65fc07 100644 --- a/src/serviceFeatures/obsidianSettingDialogue/index.ts +++ b/src/serviceFeatures/obsidianSettingDialogue/index.ts @@ -1,4 +1,4 @@ -import { createServiceFeature } from "@lib/interfaces/ServiceModule.ts"; +import { createObsidianServiceFeature } from "@/types.ts"; import { ObsidianLiveSyncSettingTab } from "@/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts"; import { EVENT_REQUEST_OPEN_SETTING_WIZARD, EVENT_REQUEST_OPEN_SETTINGS, eventHub } from "@/common/events.ts"; import type { SettingDialogueServices, SettingDialogueModules } from "./types.ts"; @@ -8,25 +8,28 @@ import { openSetting, openSettingWizard } from "./settingOperations.ts"; /** * A service feature hook that registers the plug-in setting tab and listens to settings dialogue triggers. */ -export const useObsidianSettingDialogue = createServiceFeature( - (host) => { - const state = createInitialState(); +export const useObsidianSettingDialogue = createObsidianServiceFeature< + SettingDialogueServices, + SettingDialogueModules, + "app" | "liveSyncPlugin", + void +>((host) => { + const state = createInitialState(); - const everyOnloadStart = (): Promise => { - const app = (host as any).app; - const plugin = (host as any).plugin; + const everyOnloadStart = (): Promise => { + const app = host.context.app; + const plugin = host.context.liveSyncPlugin; - state.settingTab = new ObsidianLiveSyncSettingTab(app, plugin); - plugin.addSettingTab(state.settingTab); + state.settingTab = new ObsidianLiveSyncSettingTab(app, plugin); + plugin.addSettingTab(state.settingTab); - eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => openSetting(host)); - eventHub.onEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD, () => { - void openSettingWizard(host, state); - }); + eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => openSetting(host)); + eventHub.onEvent(EVENT_REQUEST_OPEN_SETTING_WIZARD, () => { + void openSettingWizard(host, state); + }); - return Promise.resolve(true); - }; + return Promise.resolve(true); + }; - host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart); - } -); + host.services.appLifecycle.onInitialise.addHandler(everyOnloadStart); +}); diff --git a/src/serviceFeatures/obsidianSettingDialogue/obsidianSettingDialogue.unit.spec.ts b/src/serviceFeatures/obsidianSettingDialogue/obsidianSettingDialogue.unit.spec.ts index 81e5bc2..4579211 100644 --- a/src/serviceFeatures/obsidianSettingDialogue/obsidianSettingDialogue.unit.spec.ts +++ b/src/serviceFeatures/obsidianSettingDialogue/obsidianSettingDialogue.unit.spec.ts @@ -29,11 +29,14 @@ describe("ObsidianSettingDialogue Operations", () => { vi.clearAllMocks(); host = { - app: { - setting: { - open: mockOpen, - openTabById: mockOpenTabById, + context: { + app: { + setting: { + open: mockOpen, + openTabById: mockOpenTabById, + }, }, + liveSyncPlugin: {}, }, } as unknown as SettingDialogueHost; }); diff --git a/src/serviceFeatures/obsidianSettingDialogue/settingOperations.ts b/src/serviceFeatures/obsidianSettingDialogue/settingOperations.ts index 4a6c8e8..f384f20 100644 --- a/src/serviceFeatures/obsidianSettingDialogue/settingOperations.ts +++ b/src/serviceFeatures/obsidianSettingDialogue/settingOperations.ts @@ -7,8 +7,8 @@ import type { SettingDialogueState } from "./state.ts"; * @param host - The service feature host context. */ export function openSetting(host: SettingDialogueHost): void { - const app = (host as any).app; - if (app && app.setting) { + const app = host.context.app as any; + if (app?.setting) { try { app.setting.open(); app.setting.openTabById("obsidian-livesync"); diff --git a/src/serviceFeatures/obsidianSettingDialogue/types.ts b/src/serviceFeatures/obsidianSettingDialogue/types.ts index 5ff95db..eb8bdb8 100644 --- a/src/serviceFeatures/obsidianSettingDialogue/types.ts +++ b/src/serviceFeatures/obsidianSettingDialogue/types.ts @@ -1,4 +1,4 @@ -import { type NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryObsidianServices } from "@/types.ts"; /** * Service keys required by the Obsidian setting tab dialogue feature. @@ -13,4 +13,8 @@ export type SettingDialogueModules = never; /** * The host type representing the injected service container with setting tab capabilities. */ -export type SettingDialogueHost = NecessaryServices; +export type SettingDialogueHost = NecessaryObsidianServices< + SettingDialogueServices, + SettingDialogueModules, + "app" | "liveSyncPlugin" +>; diff --git a/src/serviceFeatures/replicator/ReplicateResultProcessor.ts b/src/serviceFeatures/replicator/ReplicateResultProcessor.ts deleted file mode 100644 index 7b426f1..0000000 --- a/src/serviceFeatures/replicator/ReplicateResultProcessor.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { - SYNCINFO_ID, - VER, - type AnyEntry, - type EntryDoc, - type EntryLeaf, - type LoadedEntry, - type MetaEntry, -} from "@lib/common/types"; -import { isChunk } from "@lib/common/typeUtils"; -import { - LOG_LEVEL_DEBUG, - LOG_LEVEL_INFO, - LOG_LEVEL_NOTICE, - LOG_LEVEL_VERBOSE, - Logger, - type LOG_LEVEL, -} from "@lib/common/logger"; -import { fireAndForget, isAnyNote, throttle } from "@lib/common/utils"; -import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2"; -import { serialized } from "octagonal-wheels/concurrency/lock"; -import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; -import { isNotFoundError } from "@lib/common/utils.doc"; - -const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot"; -type ReplicateResultProcessorState = { - queued: PouchDB.Core.ExistingDocument[]; - processing: PouchDB.Core.ExistingDocument[]; -}; -function shortenId(id: string): string { - return id.length > 10 ? id.substring(0, 10) : id; -} -function shortenRev(rev: string | undefined): string { - if (!rev) return "undefined"; - return rev.length > 10 ? rev.substring(0, 10) : rev; -} -export class ReplicateResultProcessor { - private log(message: string, level: LOG_LEVEL = LOG_LEVEL_INFO) { - Logger(`[ReplicateResultProcessor] ${message}`, level); - } - private logError(e: unknown) { - Logger(e, LOG_LEVEL_VERBOSE); - } - private _core: LiveSyncBaseCore; - - constructor(core: LiveSyncBaseCore) { - this._core = core; - } - - get localDatabase() { - return this._core.localDatabase; - } - get services() { - return this._core.services; - } - get core() { - return this._core; - } - - getPath(entry: AnyEntry): string { - return this.services.path.getPath(entry); - } - - public suspend() { - this._suspended = true; - } - public resume() { - this._suspended = false; - fireAndForget(() => this.runProcessQueue()); - } - - // Whether the processing is suspended - // If true, the processing queue processor bails the loop. - private _suspended: boolean = false; - - public get isSuspended() { - return ( - this._suspended || - !this.services.appLifecycle.isReady() || - this.services.setting.settings.suspendParseReplicationResult || - this.services.appLifecycle.isSuspended() - ); - } - - /** - * Take a snapshot of the current processing state. - * This snapshot is stored in the KV database for recovery on restart. - */ - protected async _takeSnapshot() { - const snapshot = { - queued: this._queuedChanges.slice(), - processing: this._processingChanges.slice(), - } satisfies ReplicateResultProcessorState; - await this.core.kvDB.set(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT, snapshot); - this.log( - `Snapshot taken. Queued: ${snapshot.queued.length}, Processing: ${snapshot.processing.length}`, - LOG_LEVEL_DEBUG - ); - this.reportStatus(); - } - /** - * Trigger taking a snapshot. - */ - protected _triggerTakeSnapshot() { - fireAndForget(() => this._takeSnapshot()); - } - /** - * Throttled version of triggerTakeSnapshot. - */ - protected triggerTakeSnapshot = throttle(() => this._triggerTakeSnapshot(), 50); - - /** - * Restore from snapshot. - */ - public async restoreFromSnapshot() { - const snapshot = await this.core.kvDB.get( - KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT - ); - if (snapshot) { - // Restoring the snapshot re-runs processing for both queued and processing items. - const newQueue = [...snapshot.processing, ...snapshot.queued, ...this._queuedChanges]; - this._queuedChanges = []; - this.enqueueAll(newQueue); - this.log( - `Restored from snapshot (${snapshot.processing.length + snapshot.queued.length} items)`, - LOG_LEVEL_INFO - ); - // await this._takeSnapshot(); - } - } - - private _restoreFromSnapshot: Promise | undefined = undefined; - - /** - * Restore from snapshot only once. - * @returns Promise that resolves when restoration is complete. - */ - public restoreFromSnapshotOnce() { - if (!this._restoreFromSnapshot) { - this._restoreFromSnapshot = this.restoreFromSnapshot(); - } - return this._restoreFromSnapshot; - } - - /** - * Perform the given procedure while counting the concurrency. - * @param proc async procedure to perform - * @param countValue reactive source to count concurrency - * @returns result of the procedure - */ - async withCounting(proc: () => Promise, countValue: ReactiveSource) { - countValue.value++; - try { - return await proc(); - } finally { - countValue.value--; - } - } - - /** - * Report the current status. - */ - protected reportStatus() { - this.services.replication.replicationResultCount.value = - this._queuedChanges.length + this._processingChanges.length; - } - - /** - * Enqueue all the given changes for processing. - * @param changes Changes to enqueue - */ - - public enqueueAll(changes: PouchDB.Core.ExistingDocument[]) { - for (const change of changes) { - // Check if the change is not a document change (e.g., chunk, versioninfo, syncinfo), and processed it directly. - const isProcessed = this.processIfNonDocumentChange(change); - if (!isProcessed) { - this.enqueueChange(change); - } - } - } - /** - * Process the change if it is not a document change. - * @param change Change to process - * @returns True if the change was processed; false otherwise - */ - protected processIfNonDocumentChange(change: PouchDB.Core.ExistingDocument) { - if (!change) { - this.log(`Received empty change`, LOG_LEVEL_VERBOSE); - return true; - } - if (isChunk(change._id)) { - // Emit event for new chunk - this.localDatabase.onNewLeaf(change as EntryLeaf); - this.log(`Processed chunk: ${shortenId(change._id)}`, LOG_LEVEL_DEBUG); - return true; - } - if (change.type == "versioninfo") { - this.log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE); - if (change.version > VER) { - // Incompatible version, stop replication. - this.core.replicator.closeReplication(); - this.log( - `Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`, - LOG_LEVEL_NOTICE - ); - } - return true; - } - if ( - change._id == SYNCINFO_ID || // Synchronisation information data - change._id.startsWith("_design") //design document - ) { - this.log(`Skipped system document: ${change._id}`, LOG_LEVEL_VERBOSE); - return true; - } - return false; - } - - /** - * Queue of changes to be processed. - */ - private _queuedChanges: PouchDB.Core.ExistingDocument[] = []; - - /** - * List of changes being processed. - */ - private _processingChanges: PouchDB.Core.ExistingDocument[] = []; - - /** - * Enqueue the given document change for processing. - * @param doc Document change to enqueue - * @returns - */ - protected enqueueChange(doc: PouchDB.Core.ExistingDocument) { - const old = this._queuedChanges.find((e) => e._id == doc._id); - const path = "path" in doc ? this.getPath(doc) : ""; - const docNote = `${path} (${shortenId(doc._id)}, ${shortenRev(doc._rev)})`; - if (old) { - if (old._rev == doc._rev) { - this.log(`[Enqueue] skipped (Already queued): ${docNote}`, LOG_LEVEL_VERBOSE); - return; - } - - const oldRev = old._rev ?? ""; - const isDeletedBefore = old._deleted === true || ("deleted" in old && old.deleted === true); - const isDeletedNow = doc._deleted === true || ("deleted" in doc && doc.deleted === true); - - // Replace the old queued change (This may performed batched updates, actually process performed always with the latest version, hence we can simply replace it if the change is the same type). - if (isDeletedBefore === isDeletedNow) { - this._queuedChanges = this._queuedChanges.filter((e) => e._id != doc._id); - this.log(`[Enqueue] requeued: ${docNote} (from rev: ${shortenRev(oldRev)})`, LOG_LEVEL_VERBOSE); - } - } - // Enqueue the change - this._queuedChanges.push(doc); - this.triggerTakeSnapshot(); - this.triggerProcessQueue(); - } - - /** - * Trigger processing of the queued changes. - */ - protected triggerProcessQueue() { - fireAndForget(() => this.runProcessQueue()); - } - - /** - * Semaphore to limit concurrent processing. - * This is the per-id semaphore + concurrency-control (max 10 concurrent = 10 documents being processed at the same time). - */ - private _semaphore = Semaphore(10); - - /** - * Flag indicating whether the process queue is currently running. - */ - private _isRunningProcessQueue: boolean = false; - - /** - * Process the queued changes. - */ - private async runProcessQueue() { - // Avoid re-entrance, suspend processing, or empty queue loop consumption. - if (this._isRunningProcessQueue) return; - if (this.isSuspended) return; - if (this._queuedChanges.length == 0) return; - try { - this._isRunningProcessQueue = true; - while (this._queuedChanges.length > 0) { - // If getting suspended, bail the loop. Some concurrent tasks may still be running. - if (this.isSuspended) { - this.log( - `Processing has got suspended. Remaining items in queue: ${this._queuedChanges.length}`, - LOG_LEVEL_INFO - ); - break; - } - - // Acquire semaphore for new processing slot - // (per-document serialisation caps concurrency). - const releaser = await this._semaphore.acquire(); - releaser(); - // Dequeue the next change - const doc = this._queuedChanges.shift(); - if (doc) { - this._processingChanges.push(doc); - void this.parseDocumentChange(doc); - } - // Take snapshot (to be restored on next startup if needed) - this.triggerTakeSnapshot(); - } - } finally { - this._isRunningProcessQueue = false; - } - } - - // Phase 1: parse replication result - /** - * Parse the given document change. - * @param change - * @returns - */ - async parseDocumentChange(change: PouchDB.Core.ExistingDocument) { - try { - if (isAnyNote(change)) { - const docMtime = change.mtime ?? 0; - const maxMTime = this.core.settings.maxMTimeForReflectEvents; - if (maxMTime > 0 && docMtime > maxMTime) { - const docPath = this.getPath(change); - this.log( - `Processing ${docPath} has been skipped due to modification time (${new Date( - docMtime * 1000 - ).toISOString()}) exceeding the limit`, - LOG_LEVEL_INFO - ); - return; - } - } - // If the document is a virtual document, process it in the virtual document processor. - if (await this.services.replication.processVirtualDocument(change)) return; - // If the document is version info, check compatibility and return. - if (isAnyNote(change)) { - const docPath = this.getPath(change); - if (!(await this.services.vault.isTargetFile(docPath))) { - this.log(`Skipped: ${docPath}`, LOG_LEVEL_VERBOSE); - return; - } - const size = change.size; - // Note that this size check depends size that in metadata, not the actual content size. - if (this.services.vault.isFileSizeTooLarge(size)) { - this.log( - `Processing ${docPath} has been skipped due to file size exceeding the limit`, - LOG_LEVEL_NOTICE - ); - return; - } - return await this.applyToDatabase(change); - } - this.log(`Skipped unexpected non-note document: ${change._id}`, LOG_LEVEL_INFO); - return; - } finally { - // Remove from processing queue - this._processingChanges = this._processingChanges.filter((e) => e !== change); - this.triggerTakeSnapshot(); - } - } - - // Phase 2: apply the document to database - protected applyToDatabase(doc: PouchDB.Core.ExistingDocument) { - return this.withCounting(async () => { - let releaser: Awaited> | undefined = undefined; - try { - releaser = await this._semaphore.acquire(); - await this._applyToDatabase(doc); - } catch (e) { - this.log(`Error while processing replication result`, LOG_LEVEL_NOTICE); - this.logError(e); - } finally { - // Remove from processing queue (To remove from "in-progress" list, and snapshot will not include it) - if (releaser) { - releaser(); - } - } - }, this.services.replication.databaseQueueCount); - } - // Phase 2.1: process the document and apply to storage - // This function is serialized per document to avoid race-condition for the same document. - private _applyToDatabase(doc_: PouchDB.Core.ExistingDocument) { - const dbDoc = doc_ as LoadedEntry; // It has no `data` - const path = this.getPath(dbDoc); - return serialized(`replication-process:${dbDoc._id}`, async () => { - const docNote = `${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)})`; - const isRequired = await this.checkIsChangeRequiredForDatabaseProcessing(dbDoc); - if (!isRequired) { - this.log(`Skipped (Not latest): ${docNote}`, LOG_LEVEL_VERBOSE); - return; - } - // If `Read chunks online` is disabled, chunks should be transferred before here. - // However, in some cases, chunks are after that. So, if missing chunks exist, we have to wait for them. - // (If `Use Only Local Chunks` is enabled, we should not attempt to fetch chunks online automatically). - - const isDeleted = dbDoc._deleted === true || ("deleted" in dbDoc && dbDoc.deleted === true); - // Gather full document if not deleted - const doc = isDeleted - ? { ...dbDoc, data: "" } - : await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true); - if (!doc) { - // Failed to gather content - this.log(`Failed to gather content of ${docNote}`, LOG_LEVEL_NOTICE); - return; - } - // Check if other processor wants to process this document, if so, skip processing here. - if (await this.services.replication.processOptionalSynchroniseResult(dbDoc)) { - // Already processed - this.log(`Processed by other processor: ${docNote}`, LOG_LEVEL_DEBUG); - } else if (this.services.vault.isValidPath(this.getPath(doc))) { - // Apply to storage if the path is valid - await this.applyToStorage(doc as MetaEntry); - this.log(`Processed: ${docNote}`, LOG_LEVEL_DEBUG); - } else { - // Should process, but have an invalid path - this.log(`Unprocessed (Invalid path): ${docNote}`, LOG_LEVEL_VERBOSE); - } - return; - }); - } - /** - * Phase 3: Apply the given entry to storage. - * @param entry - * @returns - */ - protected applyToStorage(entry: MetaEntry) { - return this.withCounting(async () => { - await this.services.replication.processSynchroniseResult(entry); - }, this.services.replication.storageApplyingCount); - } - - /** - * Check whether processing is required for the given document. - * @param dbDoc Document to check - * @returns True if processing is required; false otherwise - */ - protected async checkIsChangeRequiredForDatabaseProcessing(dbDoc: LoadedEntry): Promise { - const path = this.getPath(dbDoc); - try { - const savedDoc = await this.localDatabase.getRaw(dbDoc._id, { - conflicts: true, - revs_info: true, - }); - const newRev = dbDoc._rev ?? ""; - const latestRev = savedDoc._rev ?? ""; - const revisions = savedDoc._revs_info?.map((e) => e.rev) ?? []; - if (savedDoc._conflicts && savedDoc._conflicts.length > 0) { - // There are conflicts, so we have to process it. - // (May auto-resolve or user intervention will be occurred). - return true; - } - if (newRev == latestRev) { - // The latest revision. Simply we can process it. - return true; - } - const index = revisions.indexOf(newRev); - if (index >= 0) { - // The revision has been inserted before. - return false; // This means that the document already processed (While no conflict existed). - } - return true; // This mostly should not happen, but we have to process it just in case. - } catch (e) { - if (isNotFoundError(e)) { - // getRaw failed due to not existing, it may not be happened normally especially on replication. - // If the process caused by some other reason, we **probably** have to process it. - // Note that this is not a common case. - return true; - } else { - this.log( - `Failed to get existing document for ${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)}) `, - LOG_LEVEL_NOTICE - ); - this.logError(e); - return false; - } - } - } -} diff --git a/src/serviceFeatures/replicator/ReplicateResultProcessor.unit.spec.ts b/src/serviceFeatures/replicator/ReplicateResultProcessor.unit.spec.ts deleted file mode 100644 index 47de1ac..0000000 --- a/src/serviceFeatures/replicator/ReplicateResultProcessor.unit.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { ReplicateResultProcessor } from "./ReplicateResultProcessor"; -import { createMockServiceHub } from "../mockServiceHub"; -import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; - -describe("ReplicateResultProcessor", () => { - let mockHub: ReturnType; - - beforeEach(() => { - mockHub = createMockServiceHub(); - }); - - it("should instantiate and bind core correctly", () => { - const processor = new ReplicateResultProcessor(mockHub as any as LiveSyncBaseCore); - expect(processor).toBeDefined(); - }); - - it("should process items and take snapshot", async () => { - const processor = new ReplicateResultProcessor(mockHub as any as LiveSyncBaseCore); - - // Mock simple behaviors - (processor as any).enqueue = vi.fn(); - - (processor as any).enqueue({ id: "test", doc: { _id: "test" } }); - expect((processor as any).enqueue).toHaveBeenCalled(); - }); -}); diff --git a/src/serviceFeatures/replicator/replicateResultProcessor.ts b/src/serviceFeatures/replicator/replicateResultProcessor.ts new file mode 100644 index 0000000..0559b79 --- /dev/null +++ b/src/serviceFeatures/replicator/replicateResultProcessor.ts @@ -0,0 +1,427 @@ +import { + SYNCINFO_ID, + VER, + type AnyEntry, + type EntryDoc, + type EntryLeaf, + type LoadedEntry, + type MetaEntry, +} from "@lib/common/types"; +import { isChunk } from "@lib/common/typeUtils"; +import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@lib/common/logger"; +import { fireAndForget, isAnyNote, throttle } from "@lib/common/utils"; +import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2"; +import { serialized } from "octagonal-wheels/concurrency/lock"; +import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; +import { isNotFoundError } from "@lib/common/utils.doc"; +import type { NecessaryObsidianFeature } from "@/types"; +import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils"; + +export const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot"; + +export type ReplicateResultProcessorHost = NecessaryObsidianFeature< + "API" | "appLifecycle" | "database" | "keyValueDB" | "path" | "replication" | "replicator" | "setting" | "vault" +>; + +export type ReplicateResultProcessorSnapshot = { + queued: PouchDB.Core.ExistingDocument[]; + processing: PouchDB.Core.ExistingDocument[]; +}; + +export type ReplicateResultProcessorState = { + queuedChanges: PouchDB.Core.ExistingDocument[]; + processingChanges: PouchDB.Core.ExistingDocument[]; + suspended: boolean; + restoreFromSnapshot: Promise | undefined; + semaphore: ReturnType; + isRunningProcessQueue: boolean; + triggerTakeSnapshot: () => void; +}; + +export type ReplicateResultProcessor = { + suspend: () => void; + resume: () => void; + enqueueAll: (changes: PouchDB.Core.ExistingDocument[]) => void; + restoreFromSnapshotOnce: () => Promise; +}; + +type ReplicateResultProcessorLog = LogFunction; +const noopReplicateResultProcessorLog: ReplicateResultProcessorLog = () => undefined; + +function shortenId(id: string): string { + return id.length > 10 ? id.substring(0, 10) : id; +} + +function shortenRev(rev: string | undefined): string { + if (!rev) return "undefined"; + return rev.length > 10 ? rev.substring(0, 10) : rev; +} + +export function createReplicateResultProcessorLog(host: ReplicateResultProcessorHost): ReplicateResultProcessorLog { + return createInstanceLogFunction("ReplicateResultProcessor", host.services.API); +} + +export function createReplicateResultProcessorState( + triggerTakeSnapshot: () => void = () => undefined +): ReplicateResultProcessorState { + return { + queuedChanges: [], + processingChanges: [], + suspended: false, + restoreFromSnapshot: undefined, + semaphore: Semaphore(10), + isRunningProcessQueue: false, + triggerTakeSnapshot, + }; +} + +export function isReplicateResultProcessorSuspended( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState +) { + return ( + state.suspended || + !host.services.appLifecycle.isReady() || + host.services.setting.settings.suspendParseReplicationResult || + host.services.appLifecycle.isSuspended() + ); +} + +export function suspendReplicateResultProcessing(state: ReplicateResultProcessorState) { + state.suspended = true; +} + +export function resumeReplicateResultProcessing( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog +) { + state.suspended = false; + fireAndForget(() => runReplicateResultProcessQueue(host, state, log)); +} + +export async function takeReplicateResultProcessorSnapshot( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog +) { + const snapshot = { + queued: state.queuedChanges.slice(), + processing: state.processingChanges.slice(), + } satisfies ReplicateResultProcessorSnapshot; + await host.services.keyValueDB.kvDB.set(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT, snapshot); + log( + `Snapshot taken. Queued: ${snapshot.queued.length}, Processing: ${snapshot.processing.length}`, + LOG_LEVEL_DEBUG + ); + reportReplicateResultProcessorStatus(host, state); +} + +export async function restoreReplicateResultProcessorSnapshot( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog +) { + const snapshot = await host.services.keyValueDB.kvDB.get( + KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT + ); + if (!snapshot) return; + + // Restoring the snapshot re-runs processing for both queued and processing items. + const newQueue = [...snapshot.processing, ...snapshot.queued, ...state.queuedChanges]; + state.queuedChanges = []; + enqueueAllReplicateResults(host, state, log, newQueue); + log(`Restored from snapshot (${snapshot.processing.length + snapshot.queued.length} items)`, LOG_LEVEL_INFO); +} + +export function restoreReplicateResultProcessorSnapshotOnce( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog +) { + if (!state.restoreFromSnapshot) { + state.restoreFromSnapshot = restoreReplicateResultProcessorSnapshot(host, state, log); + } + return state.restoreFromSnapshot; +} + +export async function withCounting(proc: () => Promise, countValue: ReactiveSource) { + countValue.value++; + try { + return await proc(); + } finally { + countValue.value--; + } +} + +export function reportReplicateResultProcessorStatus( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState +) { + host.services.replication.replicationResultCount.value = + state.queuedChanges.length + state.processingChanges.length; +} + +export function enqueueAllReplicateResults( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog, + changes: PouchDB.Core.ExistingDocument[] +) { + for (const change of changes) { + const isProcessed = processIfNonDocumentChange(host, log, change); + if (!isProcessed) { + enqueueReplicateResult(host, state, log, change); + } + } +} + +export function processIfNonDocumentChange( + host: ReplicateResultProcessorHost, + log: ReplicateResultProcessorLog, + change: PouchDB.Core.ExistingDocument +) { + if (!change) { + log(`Received empty change`, LOG_LEVEL_VERBOSE); + return true; + } + if (isChunk(change._id)) { + host.services.database.localDatabase.onNewLeaf(change as EntryLeaf); + log(`Processed chunk: ${shortenId(change._id)}`, LOG_LEVEL_DEBUG); + return true; + } + if (change.type == "versioninfo") { + log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE); + if (change.version > VER) { + host.services.replicator.getActiveReplicator()?.closeReplication(); + log( + `Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`, + LOG_LEVEL_NOTICE + ); + } + return true; + } + if (change._id == SYNCINFO_ID || change._id.startsWith("_design")) { + log(`Skipped system document: ${change._id}`, LOG_LEVEL_VERBOSE); + return true; + } + return false; +} + +export function enqueueReplicateResult( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog, + doc: PouchDB.Core.ExistingDocument +) { + const old = state.queuedChanges.find((e) => e._id == doc._id); + const path = "path" in doc ? host.services.path.getPath(doc) : ""; + const docNote = `${path} (${shortenId(doc._id)}, ${shortenRev(doc._rev)})`; + if (old) { + if (old._rev == doc._rev) { + log(`[Enqueue] skipped (Already queued): ${docNote}`, LOG_LEVEL_VERBOSE); + return; + } + + const oldRev = old._rev ?? ""; + const isDeletedBefore = old._deleted === true || ("deleted" in old && old.deleted === true); + const isDeletedNow = doc._deleted === true || ("deleted" in doc && doc.deleted === true); + + if (isDeletedBefore === isDeletedNow) { + state.queuedChanges = state.queuedChanges.filter((e) => e._id != doc._id); + log(`[Enqueue] requeued: ${docNote} (from rev: ${shortenRev(oldRev)})`, LOG_LEVEL_VERBOSE); + } + } + state.queuedChanges.push(doc); + state.triggerTakeSnapshot(); + fireAndForget(() => runReplicateResultProcessQueue(host, state, log)); +} + +export async function runReplicateResultProcessQueue( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog = noopReplicateResultProcessorLog +) { + if (state.isRunningProcessQueue) return; + if (isReplicateResultProcessorSuspended(host, state)) return; + if (state.queuedChanges.length == 0) return; + try { + state.isRunningProcessQueue = true; + while (state.queuedChanges.length > 0) { + if (isReplicateResultProcessorSuspended(host, state)) { + log( + `Processing has got suspended. Remaining items in queue: ${state.queuedChanges.length}`, + LOG_LEVEL_INFO + ); + break; + } + + const releaser = await state.semaphore.acquire(); + releaser(); + const doc = state.queuedChanges.shift(); + if (doc) { + state.processingChanges.push(doc); + void parseReplicateResultDocumentChange(host, state, log, doc); + } + state.triggerTakeSnapshot(); + } + } finally { + state.isRunningProcessQueue = false; + } +} + +export async function parseReplicateResultDocumentChange( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog, + change: PouchDB.Core.ExistingDocument +) { + try { + if (isAnyNote(change)) { + const docMtime = change.mtime ?? 0; + const maxMTime = host.services.setting.settings.maxMTimeForReflectEvents; + if (maxMTime > 0 && docMtime > maxMTime) { + const docPath = host.services.path.getPath(change); + log( + `Processing ${docPath} has been skipped due to modification time (${new Date( + docMtime * 1000 + ).toISOString()}) exceeding the limit`, + LOG_LEVEL_INFO + ); + return; + } + } + if (await host.services.replication.processVirtualDocument(change)) return; + if (isAnyNote(change)) { + const docPath = host.services.path.getPath(change); + if (!(await host.services.vault.isTargetFile(docPath))) { + log(`Skipped: ${docPath}`, LOG_LEVEL_VERBOSE); + return; + } + const size = change.size; + if (host.services.vault.isFileSizeTooLarge(size)) { + log(`Processing ${docPath} has been skipped due to file size exceeding the limit`, LOG_LEVEL_NOTICE); + return; + } + return await applyReplicateResultToDatabase(host, state, log, change); + } + log(`Skipped unexpected non-note document: ${change._id}`, LOG_LEVEL_INFO); + return; + } finally { + state.processingChanges = state.processingChanges.filter((e) => e !== change); + state.triggerTakeSnapshot(); + } +} + +export function applyReplicateResultToDatabase( + host: ReplicateResultProcessorHost, + state: ReplicateResultProcessorState, + log: ReplicateResultProcessorLog, + doc: PouchDB.Core.ExistingDocument +) { + return withCounting(async () => { + let releaser: Awaited> | undefined = undefined; + try { + releaser = await state.semaphore.acquire(); + await applyReplicateResultToDatabaseInternal(host, log, doc); + } catch (e) { + log(`Error while processing replication result`, LOG_LEVEL_NOTICE); + log(e, LOG_LEVEL_VERBOSE); + } finally { + releaser?.(); + } + }, host.services.replication.databaseQueueCount); +} + +export function applyReplicateResultToDatabaseInternal( + host: ReplicateResultProcessorHost, + log: ReplicateResultProcessorLog, + doc_: PouchDB.Core.ExistingDocument +) { + const dbDoc = doc_ as LoadedEntry; + const path = host.services.path.getPath(dbDoc); + return serialized(`replication-process:${dbDoc._id}`, async () => { + const docNote = `${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)})`; + const isRequired = await checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc); + if (!isRequired) { + log(`Skipped (Not latest): ${docNote}`, LOG_LEVEL_VERBOSE); + return; + } + + const isDeleted = dbDoc._deleted === true || ("deleted" in dbDoc && dbDoc.deleted === true); + const doc = isDeleted + ? { ...dbDoc, data: "" } + : await host.services.database.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true); + if (!doc) { + log(`Failed to gather content of ${docNote}`, LOG_LEVEL_NOTICE); + return; + } + if (await host.services.replication.processOptionalSynchroniseResult(dbDoc)) { + log(`Processed by other processor: ${docNote}`, LOG_LEVEL_DEBUG); + } else if (host.services.vault.isValidPath(host.services.path.getPath(doc))) { + await applyReplicateResultToStorage(host, doc as MetaEntry); + log(`Processed: ${docNote}`, LOG_LEVEL_DEBUG); + } else { + log(`Unprocessed (Invalid path): ${docNote}`, LOG_LEVEL_VERBOSE); + } + }); +} + +export function applyReplicateResultToStorage(host: ReplicateResultProcessorHost, entry: MetaEntry) { + return withCounting(async () => { + await host.services.replication.processSynchroniseResult(entry); + }, host.services.replication.storageApplyingCount); +} + +export async function checkIsChangeRequiredForDatabaseProcessing( + host: ReplicateResultProcessorHost, + log: ReplicateResultProcessorLog, + dbDoc: LoadedEntry +): Promise { + const path = host.services.path.getPath(dbDoc); + try { + const savedDoc = await host.services.database.localDatabase.getRaw(dbDoc._id, { + conflicts: true, + revs_info: true, + }); + const newRev = dbDoc._rev ?? ""; + const latestRev = savedDoc._rev ?? ""; + const revisions = savedDoc._revs_info?.map((e) => e.rev) ?? []; + if (savedDoc._conflicts && savedDoc._conflicts.length > 0) { + return true; + } + if (newRev == latestRev) { + return true; + } + const index = revisions.indexOf(newRev); + if (index >= 0) { + return false; + } + return true; + } catch (e) { + if (isNotFoundError(e)) { + return true; + } + log( + `Failed to get existing document for ${path} (${shortenId(dbDoc._id)}, ${shortenRev(dbDoc._rev)}) `, + LOG_LEVEL_NOTICE + ); + log(e, LOG_LEVEL_VERBOSE); + return false; + } +} + +export function useReplicateResultProcessor(host: ReplicateResultProcessorHost): ReplicateResultProcessor { + const log = createReplicateResultProcessorLog(host); + const state = createReplicateResultProcessorState(); + state.triggerTakeSnapshot = throttle(() => { + fireAndForget(() => takeReplicateResultProcessorSnapshot(host, state, log)); + }, 50); + + return { + suspend: () => suspendReplicateResultProcessing(state), + resume: () => resumeReplicateResultProcessing(host, state, log), + enqueueAll: (changes) => enqueueAllReplicateResults(host, state, log, changes), + restoreFromSnapshotOnce: () => restoreReplicateResultProcessorSnapshotOnce(host, state, log), + }; +} diff --git a/src/serviceFeatures/replicator/replicateResultProcessor.unit.spec.ts b/src/serviceFeatures/replicator/replicateResultProcessor.unit.spec.ts new file mode 100644 index 0000000..7e3b621 --- /dev/null +++ b/src/serviceFeatures/replicator/replicateResultProcessor.unit.spec.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SYNCINFO_ID, VER, type EntryDoc, type LoadedEntry } from "@lib/common/types"; +import { createMockServiceHub } from "../mockServiceHub"; +import { + applyReplicateResultToDatabaseInternal, + checkIsChangeRequiredForDatabaseProcessing, + createReplicateResultProcessorState, + enqueueReplicateResult, + parseReplicateResultDocumentChange, + processIfNonDocumentChange, + restoreReplicateResultProcessorSnapshotOnce, + takeReplicateResultProcessorSnapshot, + useReplicateResultProcessor, + createReplicateResultProcessorLog, + type ReplicateResultProcessorHost, +} from "./replicateResultProcessor"; + +describe("ReplicateResultProcessor", () => { + let host: ReplicateResultProcessorHost; + const log = vi.fn(); + + beforeEach(() => { + host = createMockServiceHub() as unknown as ReplicateResultProcessorHost; + log.mockClear(); + (host.services.replicator as any).getActiveReplicator = vi.fn(); + }); + + it("creates a processor API with closure-scoped state", () => { + const processor = useReplicateResultProcessor(host); + expect(processor).toEqual({ + suspend: expect.any(Function), + resume: expect.any(Function), + enqueueAll: expect.any(Function), + restoreFromSnapshotOnce: expect.any(Function), + }); + }); + + it("logs through the host API rather than the global logger", () => { + const featureLog = createReplicateResultProcessorLog(host); + + featureLog("hello", 16 as any); + + expect(host.services.API.addLog).toHaveBeenCalledWith(expect.stringContaining("hello"), 16, ""); + }); + + it("replaces a queued document when the new revision has the same deletion state", () => { + const state = createReplicateResultProcessorState(); + state.triggerTakeSnapshot = vi.fn(); + const oldDoc = { _id: "note", _rev: "1-old", path: "note.md", type: "plain", deleted: false } as any; + const newDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", deleted: false } as any; + + enqueueReplicateResult(host, state, log, oldDoc); + enqueueReplicateResult(host, state, log, newDoc); + + expect(state.queuedChanges).toEqual([newDoc]); + expect(state.triggerTakeSnapshot).toHaveBeenCalledTimes(2); + }); + + it("keeps both queued document revisions when deletion state differs", () => { + const state = createReplicateResultProcessorState(); + state.triggerTakeSnapshot = vi.fn(); + const oldDoc = { _id: "note", _rev: "1-old", path: "note.md", type: "plain", deleted: false } as any; + const deletedDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", deleted: true } as any; + + enqueueReplicateResult(host, state, log, oldDoc); + enqueueReplicateResult(host, state, log, deletedDoc); + + expect(state.queuedChanges).toEqual([oldDoc, deletedDoc]); + }); + + it("takes and restores a processing snapshot once", async () => { + const queued = [{ _id: "queued", _rev: "1-a", type: "plain" }] as PouchDB.Core.ExistingDocument[]; + const processing = [ + { _id: "processing", _rev: "1-b", type: "plain" }, + ] as PouchDB.Core.ExistingDocument[]; + const state = createReplicateResultProcessorState(); + state.queuedChanges = queued.slice(); + state.processingChanges = processing.slice(); + + await takeReplicateResultProcessorSnapshot(host, state, log); + expect(host.services.keyValueDB.kvDB.set).toHaveBeenCalledWith("replicationResultProcessorSnapshot", { + queued, + processing, + }); + + (host.services.keyValueDB.kvDB.get as any).mockResolvedValue({ queued, processing }); + const restored = createReplicateResultProcessorState(); + restored.suspended = true; + restored.triggerTakeSnapshot = vi.fn(); + await restoreReplicateResultProcessorSnapshotOnce(host, restored, log); + await restoreReplicateResultProcessorSnapshotOnce(host, restored, log); + + expect(host.services.keyValueDB.kvDB.get).toHaveBeenCalledTimes(1); + expect(restored.queuedChanges.map((e) => e._id)).toEqual(["processing", "queued"]); + }); + + it("processes non-document changes directly", () => { + const chunk = { _id: "h:chunk", _rev: "1-a" } as any; + expect(processIfNonDocumentChange(host, log, chunk)).toBe(true); + expect(host.services.database.localDatabase.onNewLeaf).toHaveBeenCalledWith(chunk); + + expect(processIfNonDocumentChange(host, log, { _id: SYNCINFO_ID } as any)).toBe(true); + expect(processIfNonDocumentChange(host, log, { _id: "_design/local" } as any)).toBe(true); + }); + + it("closes active replication on an incompatible version document", () => { + const activeReplicator = { closeReplication: vi.fn() }; + (host.services.replicator.getActiveReplicator as any).mockReturnValue(activeReplicator); + + const result = processIfNonDocumentChange(host, log, { + _id: "version", + type: "versioninfo", + version: VER + 1, + } as any); + + expect(result).toBe(true); + expect(activeReplicator.closeReplication).toHaveBeenCalled(); + }); + + it("detects whether a database change still needs processing", async () => { + const dbDoc = { _id: "note", _rev: "2-new", path: "note.md" } as LoadedEntry; + (host.services.database.localDatabase.getRaw as any).mockResolvedValue({ + _id: "note", + _rev: "3-latest", + _revs_info: [{ rev: "2-new" }], + }); + + await expect(checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc)).resolves.toBe(false); + + (host.services.database.localDatabase.getRaw as any).mockResolvedValue({ + _id: "note", + _rev: "3-latest", + _conflicts: ["2-conflict"], + _revs_info: [{ rev: "2-new" }], + }); + await expect(checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc)).resolves.toBe(true); + }); + + it("handles not-found and unexpected errors while checking database processing necessity", async () => { + const dbDoc = { _id: "note", _rev: "2-new", path: "note.md" } as LoadedEntry; + + (host.services.database.localDatabase.getRaw as any).mockRejectedValue({ status: 404 }); + await expect(checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc)).resolves.toBe(true); + + (host.services.database.localDatabase.getRaw as any).mockRejectedValue({ status: 500 }); + await expect(checkIsChangeRequiredForDatabaseProcessing(host, log, dbDoc)).resolves.toBe(false); + expect(log).toHaveBeenCalledWith( + expect.stringContaining("Failed to get existing document"), + expect.any(Number) + ); + }); + + it("skips note parsing when the replicated file is too large", async () => { + const state = createReplicateResultProcessorState(); + const change = { + _id: "note", + _rev: "1-a", + type: "plain", + path: "note.md", + size: 100, + } as any; + state.processingChanges = [change]; + state.triggerTakeSnapshot = vi.fn(); + (host.services.vault.isFileSizeTooLarge as any).mockReturnValue(true); + + await parseReplicateResultDocumentChange(host, state, log, change); + + expect(host.services.database.localDatabase.getDBEntryFromMeta).not.toHaveBeenCalled(); + expect(state.processingChanges).toEqual([]); + expect(state.triggerTakeSnapshot).toHaveBeenCalled(); + }); + + it("lets virtual document handlers consume replicated documents", async () => { + const state = createReplicateResultProcessorState(); + const change = { + _id: "virtual", + _rev: "1-a", + type: "plain", + path: "virtual.md", + size: 1, + } as any; + (host.services.replication.processVirtualDocument as any).mockResolvedValue(true); + + await parseReplicateResultDocumentChange(host, state, log, change); + + expect(host.services.vault.isTargetFile).not.toHaveBeenCalled(); + expect(host.services.database.localDatabase.getDBEntryFromMeta).not.toHaveBeenCalled(); + }); + + it("applies gathered replicated documents to storage when no optional processor handles them", async () => { + const dbDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", size: 1 } as any; + const fullDoc = { ...dbDoc, data: "hello" }; + (host.services.database.localDatabase.getRaw as any).mockResolvedValue({ _id: "note", _rev: "2-new" }); + (host.services.database.localDatabase.getDBEntryFromMeta as any).mockResolvedValue(fullDoc); + (host.services.replication.processOptionalSynchroniseResult as any).mockResolvedValue(false); + + await applyReplicateResultToDatabaseInternal(host, log, dbDoc); + + expect(host.services.replication.processSynchroniseResult).toHaveBeenCalledWith(fullDoc); + }); + + it("skips storage application when an optional processor handles the document", async () => { + const dbDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", size: 1 } as any; + (host.services.database.localDatabase.getRaw as any).mockResolvedValue({ _id: "note", _rev: "2-new" }); + (host.services.database.localDatabase.getDBEntryFromMeta as any).mockResolvedValue({ ...dbDoc, data: "hello" }); + (host.services.replication.processOptionalSynchroniseResult as any).mockResolvedValue(true); + + await applyReplicateResultToDatabaseInternal(host, log, dbDoc); + + expect(host.services.replication.processSynchroniseResult).not.toHaveBeenCalled(); + }); + + it("skips storage application for invalid replicated paths", async () => { + const dbDoc = { _id: "note", _rev: "2-new", path: "note.md", type: "plain", size: 1 } as any; + (host.services.database.localDatabase.getRaw as any).mockResolvedValue({ _id: "note", _rev: "2-new" }); + (host.services.database.localDatabase.getDBEntryFromMeta as any).mockResolvedValue({ ...dbDoc, data: "hello" }); + (host.services.replication.processOptionalSynchroniseResult as any).mockResolvedValue(false); + (host.services.vault.isValidPath as any).mockReturnValue(false); + + await applyReplicateResultToDatabaseInternal(host, log, dbDoc); + + expect(host.services.replication.processSynchroniseResult).not.toHaveBeenCalled(); + }); +}); diff --git a/src/serviceFeatures/replicator/replicator.ts b/src/serviceFeatures/replicator/replicator.ts index a16afa3..b3e539c 100644 --- a/src/serviceFeatures/replicator/replicator.ts +++ b/src/serviceFeatures/replicator/replicator.ts @@ -1,6 +1,6 @@ import { fireAndForget } from "octagonal-wheels/promises"; import { registerReplicatorCommands } from "./commands"; -import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; +import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE } from "@lib/common/types"; import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock"; import { balanceChunkPurgedDBs } from "@lib/pouchdb/chunks"; import { purgeUnreferencedChunks } from "@lib/pouchdb/chunks"; @@ -11,13 +11,15 @@ import { scheduleTask } from "octagonal-wheels/concurrency/task"; import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "@/common/events"; import { $msg } from "@lib/common/i18n"; -import { ReplicateResultProcessor } from "./ReplicateResultProcessor"; +import { useReplicateResultProcessor, type ReplicateResultProcessor } from "./replicateResultProcessor"; import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; import { clearHandlers } from "@lib/replication/SyncParamsHandler"; import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { MARK_LOG_NETWORK_ERROR } from "@lib/services/lib/logUtils"; +import { createInstanceLogFunction, MARK_LOG_NETWORK_ERROR, type LogFunction } from "@lib/services/lib/logUtils"; import type { NecessaryObsidianFeature } from "@/types"; +const noopLog: LogFunction = () => undefined; + function isOnlineAndCanReplicate( errorManager: UnresolvedErrorManager, host: NecessaryServices<"API", never>, @@ -64,7 +66,9 @@ export type ReplicatorHost = NecessaryObsidianFeature< | "API" | "database" | "databaseEvents" + | "keyValueDB" | "path" + | "vault" | "UI", "databaseFileAccess" | "rebuilder" >; @@ -114,10 +118,10 @@ export const everyBeforeReplicateHandler = async ( return true; }; -export const cleanedHandler = async (host: ReplicatorHost, showMessage: boolean) => { +export const cleanedHandler = async (host: ReplicatorHost, showMessage: boolean, log: LogFunction = noopLog) => { const { services, serviceModules } = host; const settings = services.setting.settings; - Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); + log(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); await skipIfDuplicated("cleanup", async () => { const count = await purgeUnreferencedChunks(services.database.localDatabase.localDatabase, true); const message = `The remote database has been cleaned up. @@ -143,7 +147,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid if (!(replicator instanceof LiveSyncCouchDBReplicator)) return; const remoteDB = await replicator.connectRemoteCouchDBWithSetting(settings, services.API.isMobile(), true); if (typeof remoteDB == "string") { - Logger(remoteDB, LOG_LEVEL_NOTICE); + log(remoteDB, LOG_LEVEL_NOTICE); return false; } @@ -155,9 +159,9 @@ Even if you choose to clean up, you will see this option again if you exit Obsid await purgeUnreferencedChunks(services.database.localDatabase.localDatabase, false); services.database.localDatabase.clearCaches(); await activeReplicator.markRemoteResolved(settings); - Logger("The local database has been cleaned up.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); + log("The local database has been cleaned up.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO); } else { - Logger( + log( "Replication has been cancelled. Please try it again.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO ); @@ -168,13 +172,14 @@ Even if you choose to clean up, you will see this option again if you exit Obsid export const onReplicationFailedHandler = async ( host: ReplicatorHost, - showMessage: boolean = false + showMessage: boolean = false, + log: LogFunction = noopLog ): Promise => { const { services, serviceModules } = host; const settings = services.setting.settings; const activeReplicator = services.replicator.getActiveReplicator(); if (!activeReplicator) { - Logger(`No active replicator found`, LOG_LEVEL_INFO); + log(`No active replicator found`, LOG_LEVEL_INFO); return false; } if (activeReplicator.tweakSettingsMismatched && activeReplicator.preferredTweakValue) { @@ -182,7 +187,7 @@ export const onReplicationFailedHandler = async ( } else { if (activeReplicator.remoteLockedAndDeviceNotAccepted) { if (activeReplicator.remoteCleaned && settings.useIndexedDBAdapter) { - await cleanedHandler(host, showMessage); + await cleanedHandler(host, showMessage, log); } else { const message = $msg("Replicator.Dialogue.Locked.Message"); const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch"); @@ -198,13 +203,13 @@ export const onReplicationFailedHandler = async ( } ); if (ret == CHOICE_FETCH) { - Logger($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE); + log($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE); await serviceModules.rebuilder.scheduleFetch(); services.appLifecycle.scheduleRestart(); return false; } else if (ret == CHOICE_UNLOCK) { await activeReplicator.markRemoteResolved(settings); - Logger($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE); + log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE); return false; } } @@ -222,10 +227,10 @@ export const parseReplicationResultHandler = ( }; export function useReplicator(host: ReplicatorHost) { - const { services, serviceModules } = host; - const settings = services.setting.settings; + const { services } = host; + const log = createInstanceLogFunction("Replicator", services.API); - const processor = new ReplicateResultProcessor(host as any); + const processor = useReplicateResultProcessor(host); const unresolvedErrorManager = new UnresolvedErrorManager(services.appLifecycle); services.replicator.onReplicatorInitialised.addHandler(onReplicatorInitialisedHandler); @@ -253,7 +258,9 @@ export function useReplicator(host: ReplicatorHost) { everyBeforeReplicateHandler.bind(null, unresolvedErrorManager, processor), 100 ); - services.replication.onReplicationFailed.addHandler(onReplicationFailedHandler.bind(null, host)); + services.replication.onReplicationFailed.addHandler((showMessage) => + onReplicationFailedHandler(host, showMessage, log) + ); registerReplicatorCommands(host); } diff --git a/src/serviceFeatures/replicator/replicator.unit.spec.ts b/src/serviceFeatures/replicator/replicator.unit.spec.ts index 9aa5127..64ea958 100644 --- a/src/serviceFeatures/replicator/replicator.unit.spec.ts +++ b/src/serviceFeatures/replicator/replicator.unit.spec.ts @@ -10,19 +10,19 @@ import { onReplicationFailedHandler, } from "./replicator"; import { createMockServiceHub } from "../mockServiceHub"; -import { ReplicateResultProcessor } from "./ReplicateResultProcessor"; +import type { ReplicateResultProcessor } from "./replicateResultProcessor"; import { eventHub, EVENT_FILE_SAVED, EVENT_SETTING_SAVED } from "@/common/events"; import { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator"; import { $msg } from "@lib/common/i18n"; -vi.mock("./ReplicateResultProcessor", () => { +vi.mock("./replicateResultProcessor", () => { return { - ReplicateResultProcessor: class { - suspend = vi.fn(); - resume = vi.fn(); - restoreFromSnapshotOnce = vi.fn().mockResolvedValue(true); - enqueueAll = vi.fn(); - }, + useReplicateResultProcessor: vi.fn(() => ({ + suspend: vi.fn(), + resume: vi.fn(), + restoreFromSnapshotOnce: vi.fn().mockResolvedValue(true), + enqueueAll: vi.fn(), + })), }; }); @@ -56,6 +56,13 @@ vi.mock("@lib/replication/couchdb/LiveSyncReplicator", () => { describe("useReplicator", () => { let mockHub: ReturnType; + const createMockProcessor = (): ReplicateResultProcessor => ({ + suspend: vi.fn(), + resume: vi.fn(), + restoreFromSnapshotOnce: vi.fn().mockResolvedValue(undefined), + enqueueAll: vi.fn(), + }); + beforeEach(() => { mockHub = createMockServiceHub(); (mockHub.services as any).tweakValue = { @@ -88,7 +95,7 @@ describe("useReplicator", () => { }); it("parseReplicationResultHandler should enqueue docs", async () => { - const mockProcessor = new ReplicateResultProcessor(mockHub as any); + const mockProcessor = createMockProcessor(); const res = await parseReplicationResultHandler(mockProcessor, []); expect(mockProcessor.enqueueAll).toHaveBeenCalledWith([]); expect(res).toBe(true); @@ -144,7 +151,7 @@ describe("useReplicator", () => { it("everyOnloadAfterLoadSettingsHandler should register event listeners", async () => { vi.useFakeTimers(); try { - const mockProcessor = new ReplicateResultProcessor(mockHub as any); + const mockProcessor = createMockProcessor(); await everyOnloadAfterLoadSettingsHandler(mockHub as any, mockProcessor); (mockHub.services.setting.settings as any).syncOnSave = true; @@ -168,7 +175,7 @@ describe("useReplicator", () => { }); it("everyOnDatabaseInitializedHandler and everyBeforeReplicateHandler", async () => { - const mockProcessor = new ReplicateResultProcessor(mockHub as any); + const mockProcessor = createMockProcessor(); await everyOnDatabaseInitializedHandler(mockProcessor, false); await new Promise((resolve) => setTimeout(resolve, 1)); diff --git a/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.ts b/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.ts index a5d8bf2..914450e 100644 --- a/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.ts +++ b/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.ts @@ -1,4 +1,4 @@ -import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; +import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; import { extractObject } from "octagonal-wheels/object"; import { TweakValuesShouldMatchedTemplate, @@ -15,12 +15,17 @@ import { escapeMarkdownValue } from "@lib/common/utils.ts"; import { $msg } from "@lib/common/i18n.ts"; import { REMOTE_P2P } from "@lib/common/models/setting.const.ts"; import type { NecessaryObsidianFeature } from "@/types"; +import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils"; export type MismatchedTweaksResolverHost = NecessaryObsidianFeature< - "setting" | "tweakValue" | "replication" | "replicator" | "UI", + "API" | "setting" | "tweakValue" | "replication" | "replicator" | "UI", "rebuilder" >; +const noopLog: LogFunction = () => undefined; +const createMismatchedTweaksLog = (host: MismatchedTweaksResolverHost): LogFunction => + host.services.API ? createInstanceLogFunction("TweakMismatch", host.services.API) : noopLog; + export function valueToString(value: string | number | boolean | object | undefined): string { if (typeof value === "boolean") { return value ? "true" : "false"; @@ -36,8 +41,12 @@ export const collectMismatchedTweakKeys = (current: TweakValues, preferred: Part return items.filter((key) => current[key] !== preferred[key]); }; -export const selectNewerTweakSide = (current: TweakValues, preferred: Partial): "REMOTE" | "CURRENT" => { - Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`); +export const selectNewerTweakSide = ( + current: TweakValues, + preferred: Partial, + log: LogFunction = noopLog +): "REMOTE" | "CURRENT" => { + log(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`); const currentModified = current.tweakModified; const preferredModified = preferred.tweakModified; const hasCurrentModified = typeof currentModified === "number" && currentModified > 0; @@ -58,6 +67,7 @@ export const shouldAutoAcceptCompatibleLossy = async ( mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[] ): Promise<"REMOTE" | "CURRENT" | undefined> => { const { services } = host; + const log = createMismatchedTweaksLog(host); if (mismatchedKeys.length === 0) return undefined; const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every( (key) => CompatibleButLossyChanges.indexOf(key) !== -1 @@ -87,26 +97,27 @@ export const shouldAutoAcceptCompatibleLossy = async ( }, true ); - Logger("Auto-accept for compatible tweak mismatch has been enabled."); + log("Auto-accept for compatible tweak mismatch has been enabled."); } if (services.setting.settings.autoAcceptCompatibleTweak !== true) return undefined; - return selectNewerTweakSide(current, preferred); + return selectNewerTweakSide(current, preferred, log); }; export const onBeforeSaveSettingDataHandler = async ( next: ObsidianLiveSyncSettings, - previous: ObsidianLiveSyncSettings + previous: ObsidianLiveSyncSettings, + log: LogFunction = noopLog ) => { const tweakKeys = Object.keys(TweakValuesTemplate) as (keyof TweakValues)[]; const tweakKeysForUpdate = tweakKeys.filter((key) => key !== "tweakModified"); const hasChangedTweak = tweakKeysForUpdate.some((key) => next[key] !== previous[key]); if (!hasChangedTweak) return; - Logger( + log( `Some tweak values have been changed. ${tweakKeysForUpdate.filter((key) => next[key] !== previous[key]).join(", ")}` ); const modified = Date.now(); - Logger(`Modified: ${modified}`); + log(`Modified: ${modified}`); return await Promise.resolve({ tweakModified: modified, }); @@ -239,6 +250,7 @@ export const askResolvingMismatchedTweaksHandler = async ( host: MismatchedTweaksResolverHost ): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> => { const { services, serviceModules } = host; + const log = createMismatchedTweaksLog(host); if (!services.replicator.getActiveReplicator()?.tweakSettingsMismatched) { return "OK"; } @@ -254,7 +266,7 @@ export const askResolvingMismatchedTweaksHandler = async ( if (rebuildRequired) { await serviceModules.rebuilder.$rebuildRemote(); } - Logger($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE); + log($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE); return "CHECKAGAIN"; } if (conf) { @@ -264,7 +276,7 @@ export const askResolvingMismatchedTweaksHandler = async ( if (rebuildRequired) { await serviceModules.rebuilder.$fetchLocal(); } - Logger($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE); + log($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE); return "CHECKAGAIN"; } return "IGNORE"; @@ -275,9 +287,10 @@ export const fetchRemotePreferredTweakValuesHandler = async ( trialSetting: RemoteDBSettings ): Promise => { const { services } = host; + const log = createMismatchedTweaksLog(host); const replicator = await services.replicator.getNewReplicator(trialSetting); if (!replicator) { - Logger("The remote type is not supported for fetching preferred tweak values.", LOG_LEVEL_NOTICE); + log("The remote type is not supported for fetching preferred tweak values.", LOG_LEVEL_NOTICE); return false; } if (await replicator.tryConnectRemote(trialSetting)) { @@ -285,10 +298,10 @@ export const fetchRemotePreferredTweakValuesHandler = async ( if (preferred) { return preferred; } - Logger("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE); + log("Failed to get the preferred tweak values from the remote server.", LOG_LEVEL_NOTICE); return false; } - Logger("Failed to connect to the remote server.", LOG_LEVEL_NOTICE); + log("Failed to connect to the remote server.", LOG_LEVEL_NOTICE); return false; }; @@ -314,6 +327,7 @@ export const askUseRemoteConfigurationHandler = async ( preferred: TweakValues ): Promise<{ result: false | TweakValues; requireFetch: boolean }> => { const { services } = host; + const log = createMismatchedTweaksLog(host); const localTweaks = extractObject(TweakValuesTemplate, services.setting.settings) as TweakValues; const mismatchedKeys = collectMismatchedTweakKeys(localTweaks, preferred); const autoAcceptSide = await shouldAutoAcceptCompatibleLossy(host, state, localTweaks, preferred, mismatchedKeys); @@ -367,7 +381,7 @@ export const askUseRemoteConfigurationHandler = async ( } if (differenceCount === 0) { - Logger("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE); + log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE); return { result: false, requireFetch: false }; } const additionalMessage = @@ -404,9 +418,12 @@ export const askUseRemoteConfigurationHandler = async ( export function useMismatchedTweaksResolver(host: MismatchedTweaksResolverHost) { const { services } = host; + const log = createMismatchedTweaksLog(host); const state = { hasNotifiedAutoAcceptCompatibleUndefined: false }; - services.setting.onBeforeSaveSettingData.addHandler(onBeforeSaveSettingDataHandler); + services.setting.onBeforeSaveSettingData.addHandler((next, previous) => + onBeforeSaveSettingDataHandler(next, previous, log) + ); services.tweakValue.fetchRemotePreferred.setHandler(fetchRemotePreferredTweakValuesHandler.bind(null, host)); services.tweakValue.checkAndAskResolvingMismatched.setHandler( checkAndAskResolvingMismatchedTweaksHandler.bind(null, host, state) diff --git a/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.unit.spec.ts b/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.unit.spec.ts index c0b177d..f097c4b 100644 --- a/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.unit.spec.ts +++ b/src/serviceFeatures/tweakMismatch/mismatchedTweaksResolver.unit.spec.ts @@ -61,6 +61,9 @@ function createFeature(settingsOverride: Partial = {}) askSelectStringDialogue, }, }, + API: { + addLog: vi.fn(), + }, }, serviceModules: { rebuilder: { @@ -69,7 +72,7 @@ function createFeature(settingsOverride: Partial = {}) }, }, } as unknown as NecessaryObsidianFeature< - "setting" | "tweakValue" | "replication" | "replicator" | "UI", + "API" | "setting" | "tweakValue" | "replication" | "replicator" | "UI", "rebuilder" >; @@ -90,6 +93,25 @@ describe("useMismatchedTweaksResolver", () => { expect((host.services.tweakValue.checkAndAskResolvingMismatched as any).setHandler).toHaveBeenCalled(); }); + it("should log through the host API when applying mine", async () => { + const host = createFeature().host; + const mockReplicator = { + tweakSettingsMismatched: true, + preferredTweakValue: { tweakModified: 200 }, + setPreferredRemoteTweakSettings: vi.fn().mockResolvedValue(true), + }; + host.services.replicator.getActiveReplicator = vi.fn().mockReturnValue(mockReplicator); + (host.services.tweakValue.checkAndAskResolvingMismatched as any) = vi.fn().mockResolvedValue([true, false]); + + await askResolvingMismatchedTweaksHandler(host); + + expect(host.services.API.addLog).toHaveBeenCalledWith( + expect.stringContaining($msg("TweakMismatchResolve.Message.remoteUpdated")), + expect.any(Number), + "" + ); + }); + it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => { const { checkAndAskResolvingMismatchedTweaks, askSelectStringDialogue } = createFeature({ autoAcceptCompatibleTweak: true,