From 355d819f43d98074837ef58047bc4e0d3e227ab4 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 4 Sep 2026 06:23:54 +0000 Subject: [PATCH] Extract optional-file sync context capabilities --- ...misation_and_hidden_file_sync_ownership.md | 64 +++++--- .../optional_file_sync_architecture.md | 59 +++++--- ...ationSyncContext.composition.unit.spec.ts} | 7 +- ...customisationSyncContext.path.unit.spec.ts | 117 --------------- ...tomisationSyncContext.routing.unit.spec.ts | 4 + ...ationSyncContext.scan-routing.unit.spec.ts | 9 +- .../ConfigSync/customisationSyncContext.ts | 99 ++++--------- ...customisationSyncContext.view.unit.spec.ts | 4 +- .../customisationSyncPathOperations.ts | 59 ++++++++ ...stomisationSyncPathOperations.unit.spec.ts | 90 ++++++++++++ .../ConfigSync/customisationSyncView.ts | 6 - .../hiddenFileSyncChangeNotifier.ts | 78 ++++++++++ .../hiddenFileSyncChangeNotifier.unit.spec.ts | 136 +++++++++++++++++ ...denFileSyncContext.ownership.unit.spec.ts} | 15 +- ...hiddenFileSyncContext.routing.unit.spec.ts | 67 --------- .../HiddenFileSync/hiddenFileSyncContext.ts | 124 +++++----------- .../hiddenFileSyncContext.unit.spec.ts | 41 ++---- .../hiddenFileSyncPathAdmission.ts | 73 +++++++++ .../hiddenFileSyncPathAdmission.unit.spec.ts | 139 ++++++++++++++++++ .../scripts/customisation-sync.ts | 29 ++-- 20 files changed, 775 insertions(+), 445 deletions(-) rename src/features/ConfigSync/{customisationSyncContext.state.unit.spec.ts => customisationSyncContext.composition.unit.spec.ts} (92%) delete mode 100644 src/features/ConfigSync/customisationSyncContext.path.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncPathOperations.ts create mode 100644 src/features/ConfigSync/customisationSyncPathOperations.unit.spec.ts create mode 100644 src/features/HiddenFileSync/hiddenFileSyncChangeNotifier.ts create mode 100644 src/features/HiddenFileSync/hiddenFileSyncChangeNotifier.unit.spec.ts rename src/features/HiddenFileSync/{hiddenFileSyncContext.state.unit.spec.ts => hiddenFileSyncContext.ownership.unit.spec.ts} (83%) delete mode 100644 src/features/HiddenFileSync/hiddenFileSyncContext.routing.unit.spec.ts create mode 100644 src/features/HiddenFileSync/hiddenFileSyncPathAdmission.ts create mode 100644 src/features/HiddenFileSync/hiddenFileSyncPathAdmission.unit.spec.ts diff --git a/docs/adr/2026_09_customisation_and_hidden_file_sync_ownership.md b/docs/adr/2026_09_customisation_and_hidden_file_sync_ownership.md index 01393388..c1f35e0c 100644 --- a/docs/adr/2026_09_customisation_and_hidden_file_sync_ownership.md +++ b/docs/adr/2026_09_customisation_and_hidden_file_sync_ownership.md @@ -21,12 +21,12 @@ reflection. The constructor-name add-on lookup and the `ConfigSync` and `HiddenFileSync` add-on identities have been retired. The Customisation Sync private context coordinates its snapshot operations, -scan queues, periodic work, and focused transient-state owners. The Hidden File -Sync private context coordinates reconciliation, periodic work, and the -lifetimes of focused processed-state, change-processing, and -conflict-resolution owners. Each receives live settings and database -projections, focused storage, path, and exact-revision capabilities, and -explicit host effects rather than `LiveSyncCore`. +scan queues, periodic work, and focused path and transient-state owners. The +Hidden File Sync private context coordinates reconciliation, periodic work, +and the lifetimes of focused path-admission, notification, processed-state, +change-processing, and conflict-resolution owners. Each receives live settings +and database projections, focused storage, path, and exact-revision +capabilities, and explicit host effects rather than `LiveSyncCore`. The corresponding implemented topology is documented in [Optional-file synchronisation architecture](../design_docs/optional_file_sync_architecture.md). @@ -212,6 +212,19 @@ in-memory projection, while Hidden File Sync markers are persisted operational reconciliation state with different identity, invalidation, and deletion rules. +`CustomisationSyncPathOperations` binds live configuration-directory, mode, +and device-name projections to the pure category and V1/V2 key functions. It +has no host registration or stateful application lifetime. The context uses +this capability internally; its path helpers are not re-exported through the +real-Obsidian testing view. + +`HiddenFileSyncPathAdmission` owns the ownership-first eligibility sequence +and its parsed-pattern cache. `HiddenFileSyncChangeNotifier` owns the pending +folder set, delayed delivery, suppression checks, scheduled-task cancellation, +and Notice show/hide effect calls. The Obsidian adapter still owns the actual +Notice instance. These owners make cache and notification behaviour directly +testable without making either concern a serviceFeature. + `HiddenFileSyncChangeProcessor` owns storage and database change processing, the bounded semaphore, same-path event serialisation, activity counts, and the inherited order in which processed-state markers and transfer results settle. @@ -358,13 +371,14 @@ to the non-owner. - Replace the complete-core dependency with narrow dependencies. The private context, path module, codec module, focused presentation view, and -resource teardown are implemented. A focused catalogue-state owner holds the -rows, manifests, manifest mtime cache, reactive stores, and update progress, -while a bounded deduplicator owns recent raw-event keys. Enumeration and scan -queues remain with the orchestrating context. The context accepts only narrow, -live projections and explicit effects; an Obsidian adapter at the composition -edge owns dialogues, Notices, plug-in reload, restart, lifecycle, Vault access, -and compatibility scan telemetry. +resource teardown are implemented. A focused path capability binds live +settings and device identity to the pure path functions. A focused +catalogue-state owner holds the rows, manifests, manifest mtime cache, reactive +stores, and update progress, while a bounded deduplicator owns recent raw-event +keys. Enumeration and scan queues remain with the orchestrating context. The +context accepts only narrow, live projections and explicit effects; an +Obsidian adapter at the composition edge owns dialogues, Notices, plug-in +reload, restart, lifecycle, Vault access, and compatibility scan telemetry. ### Stage 5: extract the Hidden File Sync runtime — implemented @@ -378,11 +392,14 @@ host-owned command registration, and processor, cache, subscription, and Notice teardown are implemented. A focused processed-state owner holds all three persisted maps, their key and mtime rules, reset operations, and cross-side settlement. Database write and extraction operations consume one -narrow state port. A focused change processor owns storage and database event -processing, bounded concurrency, per-path serialisation, activity publication, -and compatibility settlement order. The context retains scan, initialisation, -notification, and reconciliation orchestration. A focused conflict-resolution -owner owns pending-path admission, +narrow state port. A focused path-admission owner holds the pattern cache and +the ownership, static-path, pattern, and ignore-file sequence. A focused change +notifier owns folder batching, delayed delivery, and teardown of its scheduled +work and Notice effect. A focused change processor owns storage and database +event processing, bounded concurrency, per-path serialisation, activity +publication, and compatibility settlement order. The context retains scan, +initialisation, and reconciliation orchestration. A focused +conflict-resolution owner owns pending-path admission, the parallel classification and serial interaction queues, automatic merge, newer-revision selection, interactive JSON application, settlement, and queue disposal. An Obsidian adapter owns JSON conflict dialogue instances, progress @@ -482,11 +499,12 @@ migration without first establishing narrow dependencies. - The legacy add-on identity and constructor-name lookup are removed. - The two contexts coordinate separate synchronisation workflows, but their dependency surfaces are explicit and do not include the complete core. - Customisation Sync delegates its derived catalogue and recent-event state, - while Hidden File Sync delegates processed-state, change-processing, and - conflict lifecycles, to focused owners. Further extraction should follow a - concrete behavioural boundary rather than create additional serviceFeatures - for private operations. + Customisation Sync delegates its path binding, derived catalogue, and + recent-event state, while Hidden File Sync delegates path admission, + notification, processed-state, change-processing, and conflict lifecycles, + to focused owners. Further extraction should follow a concrete behavioural + boundary rather than create additional serviceFeatures for private + operations. ## References diff --git a/docs/design_docs/optional_file_sync_architecture.md b/docs/design_docs/optional_file_sync_architecture.md index 3ed03e42..47cbd4cd 100644 --- a/docs/design_docs/optional_file_sync_architecture.md +++ b/docs/design_docs/optional_file_sync_architecture.md @@ -31,6 +31,7 @@ Obsidian composition (`main.ts`) | +--> pure local-path and document routing policy | | | +--> `CustomisationSyncContext` + | | +-- `CustomisationSyncPathOperations` | | +-- `CustomisationSyncCatalogueState` | | +-- recent-event deduplicator | | +-- immutable service-handler and testing views @@ -55,6 +56,14 @@ Obsidian composition (`main.ts`) | | +-- pending paths and two-stage conflict queue | | +-- automatic and interactive JSON resolution | | + | +--> `HiddenFileSyncPathAdmission` + | | +-- ownership, path, pattern, and ignore-file admission + | | +-- per-context parsed-pattern cache + | | + | +--> `HiddenFileSyncChangeNotifier` + | | +-- changed-folder batching and scheduled delivery + | | +-- suppression and Notice-effect teardown + | | | +-- immutable service-handler, command, repair, and testing views | +--> `useCustomisationSyncUI` @@ -78,13 +87,16 @@ application core. | Owner | Owns | Does not own | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `useOptionalFileSync` | Construction of both contexts, handler registration and removal, local-owner selection, namespace dispatch, compatibility callback order, and context disposal order. | Persisted feature state, synchronisation algorithms, commands, dialogues, or Notices. | -| `CustomisationSyncContext` | The `ix:` codec and path rules, scan queues, snapshot storage and application, periodic scan state, and the lifetimes of its transient-state owners. | Catalogue mutations, recent-event history, Obsidian dialogues, ribbon actions, or handler registration. | +| `CustomisationSyncContext` | The `ix:` scan and snapshot workflow, snapshot storage and application, periodic scan state, and the lifetimes of its focused owners. | Path derivation, catalogue mutations, recent-event history, Obsidian dialogues, ribbon actions, or handler registration. | +| `CustomisationSyncPathOperations` | Binding live configuration-directory, mode, and device-name projections to the pure category, target-path, V1 key, V2 key, and device-prefix functions. | I/O, mutable state, local-owner selection, or persistence. | | `CustomisationSyncCatalogueState` | Transient catalogue rows, manifest lookup and mtime cache, reactive catalogue and manifest stores, and catalogue update progress. | Database or storage I/O, scan scheduling, routing, or persistence. | | Customisation Sync event deduplicator | The bounded, newest-first keys used to admit raw configuration events once. | Scheduling, path ownership, catalogue state, or persisted data. | -| `HiddenFileSyncContext` | The `i:` scan and reconciliation workflow, exact-revision repair composition, notification batching, periodic scan state, and focused-owner lifetimes. | Change-event serialisation, processed-state representation, conflict queue state, Obsidian dialogues, or service handler registration. | +| `HiddenFileSyncContext` | The `i:` scan and reconciliation workflow, exact-revision repair composition, periodic scan state, and focused-owner lifetimes. | Path admission state, change-event serialisation, processed-state representation, notification batching, conflict queue state, Obsidian dialogues, or service handler registration. | | `HiddenFileSyncProcessedState` | Three device-local autosaved maps, exact storage and database keys, retained known mtime, reset behaviour, and storage/database settlement effects. | File transfer, scan scheduling, conflict handling, or presentation. | | `HiddenFileSyncChangeProcessor` | Storage and database change processing, same-path event serialisation, bounded concurrency, activity counts, and the inherited event-consumption and settlement order. | Full scans, initialisation policy, notification presentation, or conflict interaction. | | `HiddenFileSyncConflictResolution` | Conflict admission and deduplication, pending paths, the parallel classification and serial interaction queues, automatic merge, newer-revision policy, interactive JSON resolution, and conflict settlement. | Obsidian dialogue instances, general Hidden File Sync scans, processed-state caches, or Service handler registration. | +| `HiddenFileSyncPathAdmission` | The ownership-first eligibility sequence, hidden-path and pattern policy, asynchronous ignore-file check, and the per-context parsed-pattern cache. | Composition-level owner selection, scans, transfer, or persistence. | +| `HiddenFileSyncChangeNotifier` | Changed-folder deduplication, delayed delivery, live suppression and configuration-directory checks, scheduled-task cancellation, and the host Notice show/hide effects. | The Obsidian Notice instance, file extraction, or scan policy. | | Customisation Sync Obsidian adapter | Obsidian conflict selection, Notice presentation, plug-in reload, restart requests, Vault enumeration, progress telemetry, and platform-derived fallback device names. | Catalogue state, routing, or persisted document operations. | | Hidden File Sync Obsidian adapter | JSON conflict dialogue lifetime, progress presentation, grouped change Notices, plug-in reload actions, restart scheduling, Vault enumeration, and compatibility activity publication. | Transfer, reconciliation, processed-state, or conflict decisions. | | `useCustomisationSyncUI` | Command, ribbon, dialogue, open-request subscription, and their unload teardown. | Synchronisation state or Hidden File Sync initialisation behaviour. | @@ -93,9 +105,13 @@ application core. The two domain contexts coordinate one cohesive synchronisation workflow each. Their private operations and focused owners are not additional serviceFeatures: they do not independently register host integration or have separate -application lifetimes. `HiddenFileSyncChangeProcessor` is a focused resource -owner because its semaphore, per-path serialisation, activity counters, and -event settlement form one independently testable lifecycle. +application lifetimes. `CustomisationSyncPathOperations` is a stateless +capability which binds live inputs to pure path functions. The Hidden File Sync +path-admission owner holds the parsed-pattern cache, and its change notifier +holds the pending folder set and scheduled-task lifetime. +`HiddenFileSyncChangeProcessor` is a focused resource owner because its +semaphore, per-path serialisation, activity counters, and event settlement form +one independently testable lifecycle. `HiddenFileSyncConflictResolution` is another focused resource owner because conflict admission, pending-path identity, two serialisation stages, and disposal form a separate lifecycle. @@ -112,8 +128,10 @@ than interchangeable state semantics. Local file ownership is selected before either context processes an event. `optionalFileSyncRouting.ts` combines the configuration directory, feature enablement, Customisation Sync mode, and current path category. Hidden File -Sync target patterns and ignore-file results are evaluated only after the -static policy selects Hidden File Sync. +Sync path admission then checks ownership again before reading its current +target patterns, ignore patterns, or ignore-file result. This keeps the same +guard available to raw events, scheduled scans, and database reflection +without duplicating the policy or its cache in the context. The maintained local ownership is: @@ -187,18 +205,21 @@ also exposes immutable, explicitly internal testing views for maintained real-Obsidian contract tests. They provide named operations, including a scoped rebuild interceptor, without exposing context instances, dependency objects, queues, or writable stores. These test seams are not production service -locators and should not be used by application features. +locators and should not be used by application features. Path categorisation +and key derivation are tested directly through their focused capability rather +than being re-exported through a broad context testing view. ## Lifecycle and disposal The composition is created after the Service Hub and required ServiceModules exist, and before lifecycle-driven feature work begins. Each context creates its own periodic processor and focused resource owners. -`CustomisationSyncContext` creates one catalogue-state owner and one -recent-event deduplicator. `HiddenFileSyncContext` creates one processed-state -owner before composing database write and extraction operations around its -narrow port, then creates one change processor and one conflict-resolution -owner. The change processor owns its semaphore and activity state. +`CustomisationSyncContext` creates one path capability, one catalogue-state +owner, and one recent-event deduplicator. `HiddenFileSyncContext` creates one +path-admission owner, one change notifier, and one processed-state owner before +composing database write and extraction operations around their narrow ports. +It then creates one change processor and one conflict-resolution owner. The +change processor owns its semaphore and activity state. Full conflict scans admit discovered paths into the same queue without suspending it, so ordinary database conflict notifications continue during a slow scan. After enumeration, the operation waits for both classification and @@ -231,11 +252,13 @@ appropriate, a migration decision. ## Verification boundaries -Focused unit tests cover routing, semantic handler views, context state -isolation, teardown, initial cache selection, exact-revision repair, change -event serialisation and settlement, conflict queue admission, revision -selection, automatic and interactive merge effect ordering, conflict dialogue -adaptation, grouped Notices, and compatibility activity publication. +Focused unit tests cover routing, path-option binding, Hidden File Sync +admission ordering and cache invalidation, semantic handler views, context +owner isolation, teardown, initial cache selection, exact-revision repair, +change-event serialisation and settlement, notification batching, conflict +queue admission, revision selection, automatic and interactive merge effect +ordering, conflict dialogue adaptation, grouped Notices, and compatibility +activity publication. The boundary test prevents either domain context from regaining core or Obsidian dependencies. diff --git a/src/features/ConfigSync/customisationSyncContext.state.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.composition.unit.spec.ts similarity index 92% rename from src/features/ConfigSync/customisationSyncContext.state.unit.spec.ts rename to src/features/ConfigSync/customisationSyncContext.composition.unit.spec.ts index bb7cad84..76b8ffbf 100644 --- a/src/features/ConfigSync/customisationSyncContext.state.unit.spec.ts +++ b/src/features/ConfigSync/customisationSyncContext.composition.unit.spec.ts @@ -29,7 +29,7 @@ vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ import { CustomisationSyncContext } from "./customisationSyncContext.ts"; import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts"; -describe("CustomisationSyncContext state ownership", () => { +describe("CustomisationSyncContext composition", () => { it("does not share catalogue or presentation state between context instances", () => { const first = new CustomisationSyncContext(createCustomisationSyncTestDependencies()); const second = new CustomisationSyncContext(createCustomisationSyncTestDependencies()); @@ -66,14 +66,9 @@ describe("CustomisationSyncContext state ownership", () => { "createPluginDataExFileV2", "createPluginDataFromV2", "deleteConfigOnDatabase", - "filenameToUnifiedKey", - "filenameWithUnifiedKey", - "getFileCategory", - "isTargetPath", "scanAllConfigFiles", "scanInternalFiles", "storeCustomizationFiles", - "unifiedKeyPrefixOfTerminal", ].sort() ); expect("catalogue" in context.testing).toBe(false); diff --git a/src/features/ConfigSync/customisationSyncContext.path.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.path.unit.spec.ts deleted file mode 100644 index df25460e..00000000 --- a/src/features/ConfigSync/customisationSyncContext.path.unit.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@/deps.ts", () => ({ - diff_match_patch: class DiffMatchPatch {}, - normalizePath: vi.fn((path: string) => path), - parseYaml: vi.fn(), - Platform: {}, -})); -vi.mock("@/common/types.ts", () => ({ - ICXHeader: "ix:", - PERIODIC_PLUGIN_SWEEP: 60, -})); -vi.mock("@/common/utils.ts", () => ({ - cancelTask: vi.fn(), - EVEN: Symbol("even"), - isCustomisationSyncMetadata: vi.fn(), - isPluginMetadata: vi.fn(), - scheduleTask: vi.fn(), -})); -vi.mock("@/common/PeriodicProcessor.ts", () => ({ - PeriodicProcessor: class PeriodicProcessor {}, -})); -vi.mock("@/common/translation", () => ({ - $msg: vi.fn((message: string) => message), -})); -vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ - getObsidianCommunityPluginManager: vi.fn(), -})); - -import { CustomisationSyncContext } from "./customisationSyncContext.ts"; -import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts"; - -function createConfigSync(options: { useV2?: boolean; usePluginEtc?: boolean } = {}) { - const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext; - Object.assign(configSync, { - dependencies: createCustomisationSyncTestDependencies({ - getConfigDir: () => ".obsidian", - getSettings: () => ({ - usePluginSync: true, - usePluginSyncV2: options.useV2 ?? true, - usePluginEtc: options.usePluginEtc ?? true, - pluginSyncExtendedSetting: {}, - autoSweepPlugins: false, - autoSweepPluginsPeriodic: false, - watchInternalFileChanges: false, - notifyPluginOrSettingUpdated: false, - }), - }), - }); - return configSync; -} - -describe("compatibility: Customisation Sync paths", () => { - it.each([ - [".obsidian/app.json", "CONFIG"], - [".obsidian/themes/minimal/manifest.json", "THEME"], - [".obsidian/themes/minimal/theme.css", "THEME"], - [".obsidian/snippets/example.css", "SNIPPET"], - [".obsidian/plugins/example/manifest.json", "PLUGIN_MAIN"], - [".obsidian/plugins/example/main.js", "PLUGIN_MAIN"], - [".obsidian/plugins/example/styles.css", "PLUGIN_MAIN"], - [".obsidian/plugins/example/data.json", "PLUGIN_DATA"], - [".obsidian/plugins/example/other.json", "PLUGIN_ETC"], - [".obsidian/workspace", ""], - ["notes/example.json", "CONFIG"], - ])("classifies %s as %s", (path, expected) => { - expect(createConfigSync().testing.getFileCategory(path)).toBe(expected); - }); - - it("keeps other plug-in files outside V1 and disabled plug-in-extra synchronisation", () => { - const v1 = createConfigSync({ useV2: false }); - const withoutPluginEtc = createConfigSync({ usePluginEtc: false }); - const path = ".obsidian/plugins/example/other.json"; - - expect(v1.testing.getFileCategory(path)).toBe(""); - expect(withoutPluginEtc.testing.getFileCategory(path)).toBe(""); - }); - - it("recognises only classified files below the Obsidian configuration directory", () => { - const configSync = createConfigSync(); - - expect(configSync.testing.isTargetPath(".obsidian/app.json")).toBe(true); - expect(configSync.testing.isTargetPath(".obsidian/plugins/example/main.js")).toBe(true); - expect(configSync.testing.isTargetPath(".obsidian/workspace")).toBe(false); - expect(configSync.testing.isTargetPath("notes/example.json")).toBe(false); - }); - - it.each([ - [".obsidian/app.json", "ix:device-a/CONFIG/app.json.md"], - [".obsidian/snippets/example.css", "ix:device-a/SNIPPET/example.css.md"], - [".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example.md"], - [".obsidian/plugins/example/data.json", "ix:device-a/PLUGIN_DATA/example.md"], - ])("creates the V1 document path for %s", (path, expected) => { - expect(createConfigSync().testing.filenameToUnifiedKey(path)).toBe(expected); - }); - - it.each([ - [".obsidian/app.json", "ix:device-a/CONFIG/app.json%app.json"], - [".obsidian/snippets/example.css", "ix:device-a/SNIPPET/example.css%example.css"], - [".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example%main.js"], - [".obsidian/plugins/example/data.json", "ix:device-a/PLUGIN_DATA/example%data.json"], - ])("creates the V2 document path for %s", (path, expected) => { - expect(createConfigSync().testing.filenameWithUnifiedKey(path)).toBe(expected); - }); - - it("uses an explicit device name when supplied", () => { - const configSync = createConfigSync(); - - expect(configSync.testing.filenameToUnifiedKey(".obsidian/app.json", "device-b")).toBe( - "ix:device-b/CONFIG/app.json.md" - ); - expect(configSync.testing.filenameWithUnifiedKey(".obsidian/app.json", "device-b")).toBe( - "ix:device-b/CONFIG/app.json%app.json" - ); - expect(configSync.testing.unifiedKeyPrefixOfTerminal("device-b")).toBe("ix:device-b/"); - }); -}); diff --git a/src/features/ConfigSync/customisationSyncContext.routing.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.routing.unit.spec.ts index 0c28b799..bbf79b50 100644 --- a/src/features/ConfigSync/customisationSyncContext.routing.unit.spec.ts +++ b/src/features/ConfigSync/customisationSyncContext.routing.unit.spec.ts @@ -56,6 +56,10 @@ function createConfigSync(options: { ready?: boolean; suspended?: boolean; enabl _isMainReady: vi.fn(() => options.ready ?? true), _isMainSuspended: vi.fn(() => options.suspended ?? false), isThisModuleEnabled: vi.fn(() => options.enabled ?? true), + pathOperations: { + isTargetPath: vi.fn((path: FilePath) => path == PATH), + filenameToUnifiedKey: vi.fn(() => "ix:device-a/PLUGIN_DATA/example.md"), + }, recentProcessedInternalFiles, _log: vi.fn(), }); diff --git a/src/features/ConfigSync/customisationSyncContext.scan-routing.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.scan-routing.unit.spec.ts index 98d4a30e..d48b1a2f 100644 --- a/src/features/ConfigSync/customisationSyncContext.scan-routing.unit.spec.ts +++ b/src/features/ConfigSync/customisationSyncContext.scan-routing.unit.spec.ts @@ -81,9 +81,12 @@ function createConfigSync(options: { ownsLocalDocument, }), scanInternalFiles: vi.fn(async () => options.localFiles ?? []), - filenameToUnifiedKey: vi.fn(() => V1_PATH), - filenameWithUnifiedKey: vi.fn(() => V2_PATH), - unifiedKeyPrefixOfTerminal: vi.fn(() => "ix:device-a/"), + pathOperations: { + isTargetPath: vi.fn(() => true), + filenameToUnifiedKey: vi.fn(() => V1_PATH), + filenameWithUnifiedKey: vi.fn(() => V2_PATH), + unifiedKeyPrefixOfTerminal: vi.fn(() => "ix:device-a/"), + }, getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path), storeCustomizationFiles, storeCustomisationFileV2, diff --git a/src/features/ConfigSync/customisationSyncContext.ts b/src/features/ConfigSync/customisationSyncContext.ts index 42dd3346..7171b18a 100644 --- a/src/features/ConfigSync/customisationSyncContext.ts +++ b/src/features/ConfigSync/customisationSyncContext.ts @@ -44,19 +44,11 @@ import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import { $msg } from "@/common/translation"; import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"; import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts"; -import { - createCustomisationSyncDevicePrefix, - createCustomisationSyncV1DocumentPath, - createCustomisationSyncV2DocumentPath, - getCustomisationSyncFileCategory, - isCustomisationSyncTargetPath, - parseCustomisationSyncV2DocumentPath, -} from "./customisationSyncPaths.ts"; +import { parseCustomisationSyncV2DocumentPath } from "./customisationSyncPaths.ts"; import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts"; import type { CustomisationSyncDialogView, CustomisationSyncUIControl, - CustomisationSyncFileCategory, CustomisationSyncServiceHandlers, CustomisationSyncTestingView, IPluginDataExDisplay, @@ -83,6 +75,10 @@ import { } from "./customisationSyncReadOperations.ts"; import { CustomisationSyncCatalogueState } from "./customisationSyncCatalogueState.ts"; import { CustomisationSyncRecentEventDeduplicator } from "./customisationSyncRecentEventDeduplicator.ts"; +import { + createCustomisationSyncPathOperations, + type CustomisationSyncPathOperations, +} from "./customisationSyncPathOperations.ts"; export type { PluginDataEx, PluginDataExFile } from "./customisationSyncCodec.ts"; export type { @@ -167,6 +163,7 @@ export type CustomisationSyncContextDependencies = OptionalFileSyncFileTreeDepen export class CustomisationSyncContext implements CustomisationSyncDialogView { private readonly dependencies: CustomisationSyncContextDependencies; + private readonly pathOperations: CustomisationSyncPathOperations; private readonly catalogueState = new CustomisationSyncCatalogueState(); private readonly recentProcessedInternalFiles = new CustomisationSyncRecentEventDeduplicator(); private serviceHandlersView: CustomisationSyncServiceHandlers | undefined; @@ -186,6 +183,12 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { constructor(dependencies: CustomisationSyncContextDependencies) { this.dependencies = dependencies; + this.pathOperations = createCustomisationSyncPathOperations({ + getConfigDir: () => dependencies.getConfigDir(), + getUseV2: () => dependencies.getSettings().usePluginSyncV2, + getUsePluginEtc: () => dependencies.getSettings().usePluginEtc, + getDeviceAndVaultName: () => dependencies.getDeviceAndVaultName(), + }); this.periodicPluginSweepProcessor = dependencies.createPeriodicProcessor( async () => await this.scanAllConfigFiles(false) ); @@ -224,14 +227,6 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { configDir: this.configDir, scanInternalFiles: async () => await this.scanInternalFiles(), scanAllConfigFiles: async (showMessage: boolean) => await this.scanAllConfigFiles(showMessage), - getFileCategory: (filePath: string) => this.getFileCategory(filePath), - isTargetPath: (filePath: string) => this.isTargetPath(filePath), - filenameToUnifiedKey: (path: string, termOverride?: string) => - this.filenameToUnifiedKey(path, termOverride), - filenameWithUnifiedKey: (path: string, termOverride?: string) => - this.filenameWithUnifiedKey(path, termOverride), - unifiedKeyPrefixOfTerminal: (termOverride?: string) => - this.unifiedKeyPrefixOfTerminal(termOverride), storeCustomizationFiles: async (path: FilePath, termOverride?: string) => await this.storeCustomizationFiles(path, termOverride), deleteConfigOnDatabase: async (path: FilePathWithPrefix, forceWrite?: boolean) => @@ -362,7 +357,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { async duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise { const path = `${this.configDir}/${data.files[0].filename}` as FilePath; await this.storeCustomizationFiles(path, deviceName); - await this.updatePluginList(false, this.filenameToUnifiedKey(path, deviceName)); + await this.updatePluginList(false, this.pathOperations.filenameToUnifiedKey(path, deviceName)); } dispose() { @@ -376,20 +371,6 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { this.dependencies.hideConfigurationNotice(); } - private getFileCategory(filePath: string): CustomisationSyncFileCategory { - return getCustomisationSyncFileCategory(filePath, { - configDir: this.configDir, - useV2: this.useV2, - usePluginEtc: this.useSyncPluginEtc, - }); - } - private isTargetPath(filePath: string): boolean { - return isCustomisationSyncTargetPath(filePath, { - configDir: this.configDir, - useV2: this.useV2, - usePluginEtc: this.useSyncPluginEtc, - }); - } private async onDatabaseInitialised(showNotice: boolean) { if (!this.isThisModuleEnabled()) return true; try { @@ -504,29 +485,6 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { } ).startPipeline(); - private filenameToUnifiedKey(path: string, termOverRide?: string): FilePathWithPrefix { - const term = termOverRide || this.dependencies.getDeviceAndVaultName(); - return createCustomisationSyncV1DocumentPath(path, term, { - configDir: this.configDir, - useV2: this.useV2, - usePluginEtc: this.useSyncPluginEtc, - }); - } - - private filenameWithUnifiedKey(path: string, termOverRide?: string): FilePathWithPrefix { - const term = termOverRide || this.dependencies.getDeviceAndVaultName(); - return createCustomisationSyncV2DocumentPath(path, term, { - configDir: this.configDir, - useV2: this.useV2, - usePluginEtc: this.useSyncPluginEtc, - }); - } - - private unifiedKeyPrefixOfTerminal(termOverRide?: string): string { - const term = termOverRide || this.dependencies.getDeviceAndVaultName(); - return createCustomisationSyncDevicePrefix(term); - } - private async createPluginDataExFileV2( unifiedPathV2: FilePathWithPrefix, loaded?: LoadedEntry @@ -573,7 +531,6 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { private async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise { try { this.catalogueState.beginUpdate(); - // const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath); const { pathV1 } = parseCustomisationSyncV2DocumentPath(unifiedFilenameWithKey); const oldEntry = this.catalogueState.findPlugin(pathV1); @@ -957,7 +914,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { } private async storeCustomisationFileV2(path: FilePath, term: string, force = false) { - const vf = this.filenameWithUnifiedKey(path, term); + const vf = this.pathOperations.filenameWithUnifiedKey(path, term); return await serialized(`plugin-${vf}`, async () => { const prefixedFileName = vf; @@ -1026,7 +983,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { } const ret = await this.localDatabase.putDBEntry(saveData); this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`); - fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path))); + fireAndForget(() => this.updatePluginListV2(false, this.pathOperations.filenameWithUnifiedKey(path))); return ret; } catch (ex) { this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`); @@ -1044,11 +1001,11 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { if (this.useV2) { return await this.storeCustomisationFileV2(path, term); } - const vf = this.filenameToUnifiedKey(path, term); + const vf = this.pathOperations.filenameToUnifiedKey(path, term); // console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`); return await serialized(`plugin-${vf}`, async () => { - const category = this.getFileCategory(path); + const category = this.pathOperations.getFileCategory(path); let mtime = 0; let fileTargets = [] as FilePath[]; // let savePath = ""; @@ -1057,7 +1014,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { ? path.split("/").reverse()[0] : path.split("/").reverse()[1]; const parentPath = path.split("/").slice(0, -1).join("/"); - const prefixedFileName = this.filenameToUnifiedKey(path, term); + const prefixedFileName = this.pathOperations.filenameToUnifiedKey(path, term); const id = await this.path2id(prefixedFileName); const dt: PluginDataEx = { category: category, @@ -1187,7 +1144,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { if (!this._isMainReady()) return false; if (this._isMainSuspended()) return false; if (!this.isThisModuleEnabled()) return false; - if (!this.isTargetPath(path)) return false; + if (!this.pathOperations.isTargetPath(path)) return false; if (!this.dependencies.ownsLocalFile(path)) return false; const stat = await this.storageAccess.statHidden(path); // Make sure that target is a file. @@ -1202,7 +1159,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { return true; } // To prevent saving half-collected file sets. - const keySchedule = this.filenameToUnifiedKey(path); + const keySchedule = this.pathOperations.filenameToUnifiedKey(path); scheduleTask(keySchedule, 100, async () => { await this.storeCustomizationFiles(path); }); @@ -1223,10 +1180,16 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { const filesAll = await this.scanInternalFiles(); if (this.useV2) { const filesAllUnified = filesAll - .filter((e) => this.isTargetPath(e)) - .map((e) => [this.filenameWithUnifiedKey(e, term), e] as [FilePathWithPrefix, FilePath]); + .filter((e) => this.pathOperations.isTargetPath(e)) + .map( + (e) => + [this.pathOperations.filenameWithUnifiedKey(e, term), e] as [ + FilePathWithPrefix, + FilePath, + ] + ); const localFileMap = new Map(filesAllUnified.map((e) => [e[0], e[1]])); - const prefix = this.unifiedKeyPrefixOfTerminal(term); + const prefix = this.pathOperations.unifiedKeyPrefixOfTerminal(term); const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, { include_docs: true, }); @@ -1279,8 +1242,8 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView { fireAndForget(() => this.updatePluginList(false)); } else { const files = filesAll - .filter((e) => this.isTargetPath(e)) - .map((e) => ({ key: this.filenameToUnifiedKey(e), file: e })); + .filter((e) => this.pathOperations.isTargetPath(e)) + .map((e) => ({ key: this.pathOperations.filenameToUnifiedKey(e), file: e })); const virtualPathsOfLocalFiles = [...new Set(files.map((e) => e.key))]; const filesOnDB = ( ( diff --git a/src/features/ConfigSync/customisationSyncContext.view.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.view.unit.spec.ts index f1832453..861763bd 100644 --- a/src/features/ConfigSync/customisationSyncContext.view.unit.spec.ts +++ b/src/features/ConfigSync/customisationSyncContext.view.unit.spec.ts @@ -130,7 +130,9 @@ describe("CustomisationSyncContext dialogue view", () => { const updatePluginList = vi.fn(async () => undefined); Object.assign(configSync, { compareUsingDisplayData, - filenameToUnifiedKey: vi.fn(() => "ix:device-b/PLUGIN_DATA/example.md"), + pathOperations: { + filenameToUnifiedKey: vi.fn(() => "ix:device-b/PLUGIN_DATA/example.md"), + }, storeCustomizationFiles, updatePluginList, }); diff --git a/src/features/ConfigSync/customisationSyncPathOperations.ts b/src/features/ConfigSync/customisationSyncPathOperations.ts new file mode 100644 index 00000000..aef3a215 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncPathOperations.ts @@ -0,0 +1,59 @@ +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +import { + createCustomisationSyncDevicePrefix, + createCustomisationSyncV1DocumentPath, + createCustomisationSyncV2DocumentPath, + getCustomisationSyncFileCategory, + isCustomisationSyncTargetPath, + type CustomisationSyncFileCategory, + type CustomisationSyncPathOptions, +} from "./customisationSyncPaths.ts"; + +/** Live projections needed to derive Customisation Sync paths. */ +export type CustomisationSyncPathOperationsDependencies = Readonly<{ + getConfigDir: () => string; + getUseV2: () => boolean; + getUsePluginEtc: () => boolean; + getDeviceAndVaultName: () => string; +}>; + +/** Path operations exposed to the Customisation Sync context. */ +export type CustomisationSyncPathOperations = Readonly<{ + getFileCategory(filePath: string): CustomisationSyncFileCategory; + isTargetPath(filePath: string): boolean; + filenameToUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix; + filenameWithUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix; + unifiedKeyPrefixOfTerminal(termOverride?: string): FilePathWithPrefix; +}>; + +function getPathOptions(dependencies: CustomisationSyncPathOperationsDependencies): CustomisationSyncPathOptions { + return { + configDir: dependencies.getConfigDir(), + useV2: dependencies.getUseV2(), + usePluginEtc: dependencies.getUsePluginEtc(), + }; +} + +export function createCustomisationSyncPathOperations( + dependencies: CustomisationSyncPathOperationsDependencies +): CustomisationSyncPathOperations { + return Object.freeze({ + getFileCategory: (filePath: string) => getCustomisationSyncFileCategory(filePath, getPathOptions(dependencies)), + isTargetPath: (filePath: string) => isCustomisationSyncTargetPath(filePath, getPathOptions(dependencies)), + filenameToUnifiedKey: (path: string, termOverride?: string) => + createCustomisationSyncV1DocumentPath( + path, + termOverride || dependencies.getDeviceAndVaultName(), + getPathOptions(dependencies) + ), + filenameWithUnifiedKey: (path: string, termOverride?: string) => + createCustomisationSyncV2DocumentPath( + path, + termOverride || dependencies.getDeviceAndVaultName(), + getPathOptions(dependencies) + ), + unifiedKeyPrefixOfTerminal: (termOverride?: string) => + createCustomisationSyncDevicePrefix(termOverride || dependencies.getDeviceAndVaultName()), + }); +} diff --git a/src/features/ConfigSync/customisationSyncPathOperations.unit.spec.ts b/src/features/ConfigSync/customisationSyncPathOperations.unit.spec.ts new file mode 100644 index 00000000..b1ae46d4 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncPathOperations.unit.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { + createCustomisationSyncPathOperations, + type CustomisationSyncPathOperationsDependencies, +} from "./customisationSyncPathOperations.ts"; + +type PathState = { + configDir: string; + useV2: boolean; + usePluginEtc: boolean; + deviceAndVaultName: string; +}; + +function createOperations(state: PathState) { + const dependencies: CustomisationSyncPathOperationsDependencies = { + getConfigDir: () => state.configDir, + getUseV2: () => state.useV2, + getUsePluginEtc: () => state.usePluginEtc, + getDeviceAndVaultName: () => state.deviceAndVaultName, + }; + return createCustomisationSyncPathOperations(dependencies); +} + +describe("Customisation Sync path operations", () => { + it("reads category and target settings through live getters", () => { + const state: PathState = { + configDir: ".obsidian", + useV2: true, + usePluginEtc: true, + deviceAndVaultName: "device-a", + }; + const operations = createOperations(state); + const extraPluginFile = ".obsidian/plugins/example/settings.json"; + + expect(operations.getFileCategory(extraPluginFile)).toBe("PLUGIN_ETC"); + expect(operations.isTargetPath(extraPluginFile)).toBe(true); + + state.useV2 = false; + expect(operations.getFileCategory(extraPluginFile)).toBe(""); + expect(operations.isTargetPath(extraPluginFile)).toBe(false); + + state.useV2 = true; + state.usePluginEtc = false; + expect(operations.getFileCategory(extraPluginFile)).toBe(""); + + state.configDir = ".config"; + expect(operations.isTargetPath(extraPluginFile)).toBe(false); + expect(operations.isTargetPath(".config/plugins/example/settings.json")).toBe(false); + }); + + it("derives V1, V2, and device-prefix paths from the current term and settings", () => { + const state: PathState = { + configDir: ".obsidian", + useV2: true, + usePluginEtc: true, + deviceAndVaultName: "device-a", + }; + const operations = createOperations(state); + const path = ".obsidian/plugins/example/main.js"; + + expect(operations.filenameToUnifiedKey(path)).toBe("ix:device-a/PLUGIN_MAIN/example.md"); + expect(operations.filenameWithUnifiedKey(path)).toBe("ix:device-a/PLUGIN_MAIN/example%main.js"); + expect(operations.unifiedKeyPrefixOfTerminal()).toBe("ix:device-a/"); + + state.deviceAndVaultName = "device-b"; + expect(operations.filenameToUnifiedKey(path)).toBe("ix:device-b/PLUGIN_MAIN/example.md"); + expect(operations.filenameWithUnifiedKey(path)).toBe("ix:device-b/PLUGIN_MAIN/example%main.js"); + expect(operations.unifiedKeyPrefixOfTerminal()).toBe("ix:device-b/"); + }); + + it("keeps the existing override fallback semantics", () => { + const state: PathState = { + configDir: ".obsidian", + useV2: true, + usePluginEtc: true, + deviceAndVaultName: "device-a", + }; + const operations = createOperations(state); + const path = ".obsidian/app.json"; + + expect(operations.filenameToUnifiedKey(path, "device-b")).toBe("ix:device-b/CONFIG/app.json.md"); + expect(operations.filenameWithUnifiedKey(path, "device-b")).toBe("ix:device-b/CONFIG/app.json%app.json"); + expect(operations.unifiedKeyPrefixOfTerminal("device-b")).toBe("ix:device-b/"); + + expect(operations.filenameToUnifiedKey(path, "")).toBe("ix:device-a/CONFIG/app.json.md"); + expect(operations.filenameWithUnifiedKey(path, "")).toBe("ix:device-a/CONFIG/app.json%app.json"); + expect(operations.unifiedKeyPrefixOfTerminal("")).toBe("ix:device-a/"); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncView.ts b/src/features/ConfigSync/customisationSyncView.ts index 9089771e..bd8f616e 100644 --- a/src/features/ConfigSync/customisationSyncView.ts +++ b/src/features/ConfigSync/customisationSyncView.ts @@ -11,7 +11,6 @@ import type PouchDB from "pouchdb-core"; import type { Readable } from "svelte/store"; import type { PluginDataExFile } from "./customisationSyncCodec.ts"; -import type { CustomisationSyncFileCategory } from "./customisationSyncPaths.ts"; import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts"; import type { PluginDataExDisplayV2 } from "./customisationSyncModel.ts"; @@ -67,11 +66,6 @@ export interface CustomisationSyncTestingView { readonly configDir: string; scanInternalFiles(): Promise; scanAllConfigFiles(showMessage: boolean): Promise; - getFileCategory(filePath: string): CustomisationSyncFileCategory; - isTargetPath(filePath: string): boolean; - filenameToUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix; - filenameWithUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix; - unifiedKeyPrefixOfTerminal(termOverride?: string): string; storeCustomizationFiles(path: FilePath, termOverride?: string): Promise; deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite?: boolean): Promise; createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix): PluginDataExDisplayV2 | undefined; diff --git a/src/features/HiddenFileSync/hiddenFileSyncChangeNotifier.ts b/src/features/HiddenFileSync/hiddenFileSyncChangeNotifier.ts new file mode 100644 index 00000000..e93400df --- /dev/null +++ b/src/features/HiddenFileSync/hiddenFileSyncChangeNotifier.ts @@ -0,0 +1,78 @@ +import type { FilePath, ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +const HIDDEN_FILE_NOTIFICATION_TASK = "notify-config-change"; +const HIDDEN_FILE_NOTIFICATION_DELAY_MS = 1000; + +export type HiddenFileSyncChangeNotifierSettings = Pick; + +export type HiddenFileSyncChangeNotifierTaskScheduler = ( + key: string, + timeout: number, + operation: () => Promise | void +) => void; + +export type HiddenFileSyncChangeNotifierDependencies = { + getSettings(): HiddenFileSyncChangeNotifierSettings; + getConfigDir(): string; + scheduleTask: HiddenFileSyncChangeNotifierTaskScheduler; + cancelTask(key: string): void; + showConfigurationChangeNotice(updatedFolders: readonly string[]): void; + hideConfigurationChangeNotice(): void; +}; + +export type HiddenFileSyncChangeNotifier = { + queueNotification(path: FilePath): void; + /** Compatibility seam used by the real-Obsidian Hidden File Sync fixture. */ + showConfigurationChangeNotice(updatedFolders: readonly string[]): void; + dispose(): void; +}; + +class HiddenFileSyncChangeNotifierOwner implements HiddenFileSyncChangeNotifier { + private readonly queuedNotificationFiles = new Set(); + private disposed = false; + + constructor(private readonly dependencies: HiddenFileSyncChangeNotifierDependencies) {} + + queueNotification(path: FilePath): void { + if (this.disposed) return; + if (this.dependencies.getSettings().suppressNotifyHiddenFilesChange) return; + + const configDir = this.dependencies.getConfigDir(); + if (!path.startsWith(configDir)) return; + + const folder = path.split("/").slice(0, -1).join("/"); + this.queuedNotificationFiles.add(folder); + this.dependencies.scheduleTask(HIDDEN_FILE_NOTIFICATION_TASK, HIDDEN_FILE_NOTIFICATION_DELAY_MS, () => { + this.flush(); + }); + } + + showConfigurationChangeNotice(updatedFolders: readonly string[]): void { + this.queuedNotificationFiles.clear(); + for (const folder of updatedFolders) { + this.queuedNotificationFiles.add(folder); + } + this.flush(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.queuedNotificationFiles.clear(); + this.dependencies.cancelTask(HIDDEN_FILE_NOTIFICATION_TASK); + this.dependencies.hideConfigurationChangeNotice(); + } + + private flush(): void { + const updatedFolders = [...this.queuedNotificationFiles]; + this.queuedNotificationFiles.clear(); + if (this.disposed) return; + this.dependencies.showConfigurationChangeNotice(updatedFolders); + } +} + +export function createHiddenFileSyncChangeNotifier( + dependencies: HiddenFileSyncChangeNotifierDependencies +): HiddenFileSyncChangeNotifier { + return new HiddenFileSyncChangeNotifierOwner(dependencies); +} diff --git a/src/features/HiddenFileSync/hiddenFileSyncChangeNotifier.unit.spec.ts b/src/features/HiddenFileSync/hiddenFileSyncChangeNotifier.unit.spec.ts new file mode 100644 index 00000000..ce86a5d4 --- /dev/null +++ b/src/features/HiddenFileSync/hiddenFileSyncChangeNotifier.unit.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +import { + createHiddenFileSyncChangeNotifier, + type HiddenFileSyncChangeNotifierDependencies, +} from "./hiddenFileSyncChangeNotifier.ts"; + +type ScheduledOperation = { + key: string; + timeout: number; + operation: () => Promise | void; +}; + +function createFixture(overrides: Partial = {}): { + notifier: ReturnType; + scheduled: ScheduledOperation[]; + settings: { suppressNotifyHiddenFilesChange: boolean }; + configDir: { value: string }; + showConfigurationChangeNotice: ReturnType; + hideConfigurationChangeNotice: ReturnType; + scheduleTask: ReturnType; + cancelTask: ReturnType; +} { + const scheduled: ScheduledOperation[] = []; + const settings = { suppressNotifyHiddenFilesChange: false }; + const configDir = { value: ".obsidian" }; + const showConfigurationChangeNotice = vi.fn(); + const hideConfigurationChangeNotice = vi.fn(); + const scheduleTask = vi.fn((key, timeout, operation) => { + scheduled.push({ key, timeout, operation }); + }); + const cancelTask = vi.fn(); + const dependencies: HiddenFileSyncChangeNotifierDependencies = { + getSettings: () => settings, + getConfigDir: () => configDir.value, + scheduleTask, + cancelTask, + showConfigurationChangeNotice, + hideConfigurationChangeNotice, + ...overrides, + }; + + return { + notifier: createHiddenFileSyncChangeNotifier(dependencies), + scheduled, + settings, + configDir, + showConfigurationChangeNotice, + hideConfigurationChangeNotice, + scheduleTask, + cancelTask, + }; +} + +describe("Hidden File Sync change notifier", () => { + it("queues distinct parent folders and flushes them in insertion order", () => { + const fixture = createFixture(); + + fixture.notifier.queueNotification(".obsidian/plugins/alpha/data.json" as FilePath); + fixture.notifier.queueNotification(".obsidian/plugins/beta/data.json" as FilePath); + fixture.notifier.queueNotification(".obsidian/plugins/alpha/main.js" as FilePath); + + expect(fixture.scheduleTask).toHaveBeenCalledTimes(3); + expect(fixture.scheduleTask).toHaveBeenLastCalledWith("notify-config-change", 1000, expect.any(Function)); + + fixture.scheduled[fixture.scheduled.length - 1]?.operation(); + + expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith([ + ".obsidian/plugins/alpha", + ".obsidian/plugins/beta", + ]); + }); + + it("uses live suppression and configuration-directory dependencies", () => { + const fixture = createFixture(); + + fixture.settings.suppressNotifyHiddenFilesChange = true; + fixture.notifier.queueNotification(".obsidian/plugins/suppressed/data.json" as FilePath); + fixture.settings.suppressNotifyHiddenFilesChange = false; + fixture.notifier.queueNotification("other/plugins/outside/data.json" as FilePath); + fixture.configDir.value = "other"; + fixture.notifier.queueNotification("other/plugins/inside/data.json" as FilePath); + + expect(fixture.scheduled).toHaveLength(1); + fixture.scheduled[0]?.operation(); + + expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith(["other/plugins/inside"]); + }); + + it("clears the batch before displaying it", () => { + const fixture = createFixture(); + fixture.showConfigurationChangeNotice.mockImplementation(() => { + fixture.notifier.queueNotification(".obsidian/plugins/new/data.json" as FilePath); + }); + fixture.notifier.queueNotification(".obsidian/plugins/old/data.json" as FilePath); + + fixture.scheduled[0]?.operation(); + expect(fixture.showConfigurationChangeNotice).toHaveBeenNthCalledWith(1, [".obsidian/plugins/old"]); + + fixture.scheduled[1]?.operation(); + expect(fixture.showConfigurationChangeNotice).toHaveBeenNthCalledWith(2, [".obsidian/plugins/new"]); + }); + + it("supports the immediate fixture seam without scheduling another task", () => { + const fixture = createFixture(); + + fixture.notifier.showConfigurationChangeNotice([ + ".obsidian/plugins/alpha", + ".obsidian/plugins/beta", + ".obsidian/plugins/alpha", + ]); + + expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith([ + ".obsidian/plugins/alpha", + ".obsidian/plugins/beta", + ]); + expect(fixture.scheduleTask).not.toHaveBeenCalled(); + }); + + it("cancels pending work, hides the Notice, and ignores later work on disposal", () => { + const fixture = createFixture(); + fixture.notifier.queueNotification(".obsidian/plugins/example/data.json" as FilePath); + + fixture.notifier.dispose(); + fixture.notifier.dispose(); + fixture.scheduled[0]?.operation(); + fixture.notifier.queueNotification(".obsidian/plugins/later/data.json" as FilePath); + + expect(fixture.cancelTask).toHaveBeenCalledOnce(); + expect(fixture.cancelTask).toHaveBeenCalledWith("notify-config-change"); + expect(fixture.hideConfigurationChangeNotice).toHaveBeenCalledOnce(); + expect(fixture.showConfigurationChangeNotice).not.toHaveBeenCalled(); + expect(fixture.scheduleTask).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/HiddenFileSync/hiddenFileSyncContext.state.unit.spec.ts b/src/features/HiddenFileSync/hiddenFileSyncContext.ownership.unit.spec.ts similarity index 83% rename from src/features/HiddenFileSync/hiddenFileSyncContext.state.unit.spec.ts rename to src/features/HiddenFileSync/hiddenFileSyncContext.ownership.unit.spec.ts index 62d81dd6..7fbdee92 100644 --- a/src/features/HiddenFileSync/hiddenFileSyncContext.state.unit.spec.ts +++ b/src/features/HiddenFileSync/hiddenFileSyncContext.ownership.unit.spec.ts @@ -37,16 +37,17 @@ function createContext(processedFiles = new Map()) { return { context, hideConfigurationChangeNotice, periodicProcessor, publishActivity }; } -describe("HiddenFileSyncContext state ownership", () => { - it("owns caches, concurrency controls, processors, and conflict owners per context instance", () => { +describe("HiddenFileSyncContext ownership and start-up lifecycle", () => { + it("creates each stateful capability owner per context instance", () => { const first = createContext(); const second = createContext(); - getPrivate>(first.context, "queuedNotificationFiles").add(".obsidian/plugins/first"); - getPrivate>(first.context, "cacheFileRegExps").set("first", []); - - expect(getPrivate>(second.context, "queuedNotificationFiles").size).toBe(0); - expect(getPrivate>(second.context, "cacheFileRegExps")).toEqual(new Map()); + expect(getPrivate(first.context, "pathAdmission")).not.toBe( + getPrivate(second.context, "pathAdmission") + ); + expect(getPrivate(first.context, "changeNotifier")).not.toBe( + getPrivate(second.context, "changeNotifier") + ); expect(first.context.testing.conflictResolution).not.toBe(second.context.testing.conflictResolution); expect(getProcessedState(first.context)).not.toBe(getProcessedState(second.context)); expect(getPrivate(first.context, "changeProcessor")).not.toBe( diff --git a/src/features/HiddenFileSync/hiddenFileSyncContext.routing.unit.spec.ts b/src/features/HiddenFileSync/hiddenFileSyncContext.routing.unit.spec.ts deleted file mode 100644 index 32e2e582..00000000 --- a/src/features/HiddenFileSync/hiddenFileSyncContext.routing.unit.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; - -vi.mock("@/deps.ts", () => ({})); -vi.mock("./configureHiddenFileSyncMode.ts", () => ({ - configureHiddenFileSyncMode: vi.fn(), -})); - -import { HiddenFileSyncContext } from "./hiddenFileSyncContext.ts"; - -const PATH = ".obsidian/plugins/example/data.json" as FilePath; - -function isTargetFile(context: HiddenFileSyncContext, path: FilePath): Promise { - return (context as unknown as { isTargetFile(path: FilePath): Promise }).isTargetFile(path); -} - -function isTargetFileEligible(context: HiddenFileSyncContext, path: FilePath): Promise { - return (context as unknown as { isTargetFileEligible(path: FilePath): Promise }).isTargetFileEligible(path); -} - -function createHiddenFileSync( - options: { owned?: boolean; ignoredByIgnoreFile?: boolean; patternMatch?: boolean } = {} -) { - const ownsLocalFile = vi.fn(() => options.owned ?? true); - const isIgnoredByIgnoreFile = vi.fn(async () => options.ignoredByIgnoreFile ?? false); - const patternTest = vi.fn(() => options.patternMatch ?? true); - const parseRegExpSettings = vi.fn(() => ({ - ignoreFilter: [], - targetFilter: options.patternMatch === undefined ? [] : [{ test: patternTest }], - })); - const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; - Object.assign(hiddenFileSync, { - dependencies: { ownsLocalFile, isIgnoredByIgnoreFile }, - parseRegExpSettings, - }); - return { hiddenFileSync, isIgnoredByIgnoreFile, parseRegExpSettings, ownsLocalFile }; -} - -describe("Hidden File Sync local-path admission", () => { - it("checks composition ownership before Hidden File Sync filters", async () => { - const { hiddenFileSync, parseRegExpSettings, ownsLocalFile } = createHiddenFileSync({ owned: false }); - - await expect(isTargetFile(hiddenFileSync, PATH)).resolves.toBe(false); - expect(ownsLocalFile).toHaveBeenCalledWith(PATH); - expect(parseRegExpSettings).not.toHaveBeenCalled(); - }); - - it("keeps target patterns and ignore-file results as Hidden File Sync eligibility", async () => { - const patternExcluded = createHiddenFileSync({ patternMatch: false }); - await expect(isTargetFile(patternExcluded.hiddenFileSync, PATH)).resolves.toBe(false); - expect(patternExcluded.isIgnoredByIgnoreFile).not.toHaveBeenCalled(); - - const ignoreFileExcluded = createHiddenFileSync({ ignoredByIgnoreFile: true }); - await expect(isTargetFile(ignoreFileExcluded.hiddenFileSync, PATH)).resolves.toBe(false); - expect(ignoreFileExcluded.isIgnoredByIgnoreFile).toHaveBeenCalledWith(PATH); - - const admitted = createHiddenFileSync(); - await expect(isTargetFile(admitted.hiddenFileSync, PATH)).resolves.toBe(true); - }); - - it("evaluates eligibility without consulting the composition owner", async () => { - const { hiddenFileSync, ownsLocalFile } = createHiddenFileSync({ owned: false }); - - await expect(isTargetFileEligible(hiddenFileSync, PATH)).resolves.toBe(true); - expect(ownsLocalFile).not.toHaveBeenCalled(); - }); -}); diff --git a/src/features/HiddenFileSync/hiddenFileSyncContext.ts b/src/features/HiddenFileSync/hiddenFileSyncContext.ts index 70978b5d..d792155e 100644 --- a/src/features/HiddenFileSync/hiddenFileSyncContext.ts +++ b/src/features/HiddenFileSync/hiddenFileSyncContext.ts @@ -45,7 +45,6 @@ import { type HiddenFileSyncTestingRebuild, type HiddenFileSyncTestingView, } from "./hiddenFileSyncViews.ts"; -import { isHiddenFileSyncPath, matchesHiddenFileSyncPatterns } from "./hiddenFileSyncPathPolicy.ts"; import { describeHiddenFileSyncDocument, getHiddenFileSyncComparisonMTime } from "./hiddenFileSyncState.ts"; import { collectOptionalFileSyncFiles, @@ -82,6 +81,14 @@ import { createHiddenFileSyncChangeProcessor, type HiddenFileSyncChangeProcessor, } from "./hiddenFileSyncChangeProcessor.ts"; +import { + createHiddenFileSyncPathAdmission, + type HiddenFileSyncPathAdmission, +} from "./hiddenFileSyncPathAdmission.ts"; +import { + createHiddenFileSyncChangeNotifier, + type HiddenFileSyncChangeNotifier, +} from "./hiddenFileSyncChangeNotifier.ts"; type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce"; export type HiddenFileSyncProgress = { @@ -90,8 +97,6 @@ export type HiddenFileSyncProgress = { done(message?: string): void; }; -const HIDDEN_FILE_NOTIFICATION_TASK = "notify-config-change"; - type HiddenFileSyncSettings = Pick< ObsidianLiveSyncSettings, | "syncInternalFiles" @@ -175,6 +180,8 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { private readonly databaseExtractionOperations: HiddenFileSyncDatabaseExtractionOperations; private readonly conflictResolution: HiddenFileSyncConflictResolution; private readonly changeProcessor: HiddenFileSyncChangeProcessor; + private readonly pathAdmission: HiddenFileSyncPathAdmission; + private readonly changeNotifier: HiddenFileSyncChangeNotifier; readonly serviceHandlers: HiddenFileSyncServiceHandlerView; readonly testing: HiddenFileSyncTestingView; readonly repair: HiddenFileSyncRepairView; @@ -184,6 +191,22 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { constructor(dependencies: HiddenFileSyncContextDependencies) { this.dependencies = dependencies; + this.pathAdmission = createHiddenFileSyncPathAdmission({ + getTargetPatternSource: () => dependencies.getSettings().syncInternalFilesTargetPatterns, + getIgnorePatternSource: () => dependencies.getSettings().syncInternalFilesIgnorePatterns, + getFileRegExp: (key) => dependencies.getFileRegExp(key), + isIgnoredByIgnoreFile: async (path) => await dependencies.isIgnoredByIgnoreFile(path), + ownsLocalFile: (path) => dependencies.ownsLocalFile(path), + }); + this.changeNotifier = createHiddenFileSyncChangeNotifier({ + getSettings: () => dependencies.getSettings(), + getConfigDir: () => dependencies.getConfigDir(), + scheduleTask: (key, timeout, operation) => scheduleTask(key, timeout, operation), + cancelTask: (key) => cancelTask(key), + showConfigurationChangeNotice: (updatedFolders) => + dependencies.showConfigurationChangeNotice(updatedFolders), + hideConfigurationChangeNotice: () => dependencies.hideConfigurationChangeNotice(), + }); this.processedState = createHiddenFileSyncProcessedState({ getKeyValueDatabase: () => this.dependencies.getKeyValueDatabase(), getLocalDatabase: () => this.dependencies.getLocalDatabase(), @@ -224,7 +247,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { await writeHiddenFileFromDatabase(dependencies, path, entry, force), deleteStorageFile: async (path) => await deleteHiddenFileFromStorage(dependencies, path), processedState: this.processedState, - queueNotification: (path) => this.queueNotification(path), + queueNotification: (path) => this.changeNotifier.queueNotification(path), log: (message, level, key) => dependencies.log(message, level, key), }); this.conflictResolution = createHiddenFileSyncConflictResolution({ @@ -299,7 +322,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { onDatabaseInitialised: async (showNotice) => await this.onDatabaseInitialised(showNotice), suspendExtraSync: async () => await this.suspendExtraSync(), configureOptionalSyncFeature: async (mode) => await this.configureOptionalSyncFeature(mode), - isTargetFileEligible: async (path) => await this.isTargetFileEligible(path), + isTargetFileEligible: async (path) => await this.pathAdmission.isTargetFileEligible(path), queueConflict: async (path) => await this.queueConflict(path), }); this.testing = createHiddenFileSyncTestingView({ @@ -312,13 +335,8 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { await this.initialiseInternalFileSync(direction, showMessage, targetFiles), conflictResolution: this.conflictResolution.testing, readFileWithInfo: async (path) => await readHiddenFileWithInfo(dependencies, path), - showConfigurationChangeNotice: (updatedFolders) => { - this.queuedNotificationFiles.clear(); - for (const folder of updatedFolders) { - this.queuedNotificationFiles.add(folder); - } - this.notifyConfigChange(); - }, + showConfigurationChangeNotice: (updatedFolders) => + this.changeNotifier.showConfigurationChangeNotice(updatedFolders), interceptRebuildMerging: (interceptor) => { const previousHook = this.rebuildMergingHook; const runRebuild = async (showNotice: boolean, targetFiles?: FilePath[] | false) => @@ -393,12 +411,10 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { this.periodicInternalFileScanProcessor?.disable(); this.changeProcessor.dispose(); this.conflictResolution.dispose(); + this.pathAdmission.dispose(); + this.changeNotifier.dispose(); this.rebuildMergingHook = undefined; - this.queuedNotificationFiles.clear(); - this.cacheFileRegExps.clear(); - cancelTask(HIDDEN_FILE_NOTIFICATION_TASK); this.dependencies.closeJsonConflictDialogs(); - this.dependencies.hideConfigurationChangeNotice(); } // The key-value database becomes available before this lifecycle callback. @@ -432,7 +448,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { } updateSettingCache() { - this.cacheFileRegExps.clear(); + this.pathAdmission.invalidatePatternCache(); } private isReady() { @@ -470,7 +486,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { ? this.settings.syncInternalFilesInterval * 1000 : 0 ); - this.cacheFileRegExps.clear(); + this.pathAdmission.invalidatePatternCache(); return Promise.resolve(true); } @@ -487,7 +503,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView { //system file const filename = this.getPath(doc); const unprefixedPath = stripAllPrefixes(filename); - if (!(await this.isTargetFile(stripAllPrefixes(unprefixedPath)))) { + if (!(await this.pathAdmission.isTargetFile(stripAllPrefixes(unprefixedPath)))) { this._log( `Skipped processing sync file:${unprefixedPath} (Not Hidden File Sync target)`, LOG_LEVEL_VERBOSE @@ -595,7 +611,7 @@ Offline Changed files: ${processFiles.length}`; forceWrite = false, includeDeleted = true ): Promise { - if (!(await this.isTargetFile(path))) { + if (!(await this.pathAdmission.isTargetFile(path))) { this._log( `Storage file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`, LOG_LEVEL_VERBOSE @@ -619,44 +635,6 @@ Offline Changed files: ${processFiles.length}`; // --> Database Event Functions - private readonly cacheFileRegExps = new Map(); - /** - * Parses the regular expression settings for hidden file synchronization. - * @returns An object containing the ignore and target filters. - */ - private parseRegExpSettings() { - const regExpKey = `${this.settings.syncInternalFilesTargetPatterns}||${this.settings.syncInternalFilesIgnorePatterns}`; - let ignoreFilter: CustomRegExp[]; - let targetFilter: CustomRegExp[]; - if (this.cacheFileRegExps.has(regExpKey)) { - const cached = this.cacheFileRegExps.get(regExpKey)!; - ignoreFilter = cached[1]; - targetFilter = cached[0]; - } else { - ignoreFilter = this.dependencies.getFileRegExp("syncInternalFilesIgnorePatterns"); - targetFilter = this.dependencies.getFileRegExp("syncInternalFilesTargetPatterns"); - this.cacheFileRegExps.clear(); - this.cacheFileRegExps.set(regExpKey, [targetFilter, ignoreFilter]); - } - return { ignoreFilter, targetFilter }; - } - - private async isTargetFileEligible(path: FilePath): Promise { - const result = matchesHiddenFileSyncPatterns(path, this.parseRegExpSettings()) && isHiddenFileSyncPath(path); - // console.warn(`Assertion: isTargetFile(${path}) : ${result ? "✔️" : "❌"}`); - if (!result) { - return false; - } - const resultByFile = await this.dependencies.isIgnoredByIgnoreFile(path); - // console.warn(`${path} -> isIgnoredByIgnoreFile: ${resultByFile ? "❌" : "✔️"}`); - return !resultByFile; - } - - private async isTargetFile(path: FilePath): Promise { - if (this.dependencies?.ownsLocalFile(path) === false) return false; - return await this.isTargetFileEligible(path); - } - private async trackScannedDatabaseChange( processFiles: MetaEntry[], showNotice: boolean = false, @@ -670,7 +648,7 @@ Offline Changed files: ${processFiles.length}`; const processes = processFiles.map(async (file) => { try { const path = stripAllPrefixes(this.getPath(file)); - if (!(await this.isTargetFile(path))) { + if (!(await this.pathAdmission.isTargetFile(path))) { this._log( `Database file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`, LOG_LEVEL_VERBOSE @@ -834,30 +812,6 @@ Offline Changed files: ${files.length}`; // <-- Database Event Functions - // --> Notification for Config Change - private readonly queuedNotificationFiles = new Set(); - private notifyConfigChange() { - const updatedFolders = [...this.queuedNotificationFiles]; - this.queuedNotificationFiles.clear(); - if (this.disposed) return; - this.dependencies.showConfigurationChangeNotice(updatedFolders); - } - - private queueNotification(key: FilePath) { - if (this.disposed) return; - if (this.settings.suppressNotifyHiddenFilesChange) { - return; - } - const configDir = this.dependencies.getConfigDir(); - if (!key.startsWith(configDir)) return; - const dirName = key.split("/").slice(0, -1).join("/"); - this.queuedNotificationFiles.add(dirName); - scheduleTask(HIDDEN_FILE_NOTIFICATION_TASK, 1000, () => { - this.notifyConfigChange(); - }); - } - // <-- Notification for Config Change - // --> Initialization functions private async rebuildMerging(showNotice: boolean, targetFiles: FilePath[] | false = false) { @@ -948,7 +902,7 @@ Offline Changed files: ${files.length}`; .map((e) => e.doc) as MetaEntry[]; const files = [] as MetaEntry[]; for (const file of allFiles) { - if (await this.isTargetFile(stripAllPrefixes(this.getPath(file)))) { + if (await this.pathAdmission.isTargetFile(stripAllPrefixes(this.getPath(file)))) { files.push(file); } } @@ -1109,7 +1063,7 @@ Offline Changed files: ${files.length}`; const findRoot = this.dependencies.getRootPath(); const filenames = await collectOptionalFileSyncFiles(this.dependencies, findRoot, { - shouldInclude: (path) => this.isTargetFile(path as FilePath), + shouldInclude: (path) => this.pathAdmission.isTargetFile(path as FilePath), onError: (path, error) => { this._log(`Could not traverse(HiddenSync):${path}`, LOG_LEVEL_INFO); this._log(error, LOG_LEVEL_VERBOSE); diff --git a/src/features/HiddenFileSync/hiddenFileSyncContext.unit.spec.ts b/src/features/HiddenFileSync/hiddenFileSyncContext.unit.spec.ts index fb30923d..10975395 100644 --- a/src/features/HiddenFileSync/hiddenFileSyncContext.unit.spec.ts +++ b/src/features/HiddenFileSync/hiddenFileSyncContext.unit.spec.ts @@ -14,24 +14,22 @@ function callPrivate unknown>(context: HiddenFil return operation.bind(context) as T; } -describe("HiddenFileSyncContext configuration-change notices", () => { - it("releases processors, transient queues, the pattern cache, activity, and the host Notice effect", () => { +describe("HiddenFileSyncContext lifecycle", () => { + it("releases its processors, capability owners, and host conflict dialogues", () => { const periodicInternalFileScanProcessor = { disable: vi.fn() }; const conflictResolution = { dispose: vi.fn() }; - const queuedNotificationFiles = new Set([".obsidian/plugins/example"]); - const cacheFileRegExps = new Map([["patterns", []]]); - const publishActivity = vi.fn(); const changeProcessor = { dispose: vi.fn() }; - const hideConfigurationChangeNotice = vi.fn(); + const pathAdmission = { dispose: vi.fn() }; + const changeNotifier = { dispose: vi.fn() }; const closeJsonConflictDialogs = vi.fn(); const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; Object.assign(hiddenFileSync, { - dependencies: { publishActivity, hideConfigurationChangeNotice, closeJsonConflictDialogs }, + dependencies: { closeJsonConflictDialogs }, periodicInternalFileScanProcessor, conflictResolution, changeProcessor, - queuedNotificationFiles, - cacheFileRegExps, + pathAdmission, + changeNotifier, eventCount: 4, processingCount: 2, }); @@ -41,11 +39,10 @@ describe("HiddenFileSyncContext configuration-change notices", () => { expect(periodicInternalFileScanProcessor.disable).toHaveBeenCalledOnce(); expect(conflictResolution.dispose).toHaveBeenCalledOnce(); - expect(queuedNotificationFiles.size).toBe(0); - expect(cacheFileRegExps.size).toBe(0); expect(changeProcessor.dispose).toHaveBeenCalledOnce(); + expect(pathAdmission.dispose).toHaveBeenCalledOnce(); + expect(changeNotifier.dispose).toHaveBeenCalledOnce(); expect(closeJsonConflictDialogs).toHaveBeenCalledOnce(); - expect(hideConfigurationChangeNotice).toHaveBeenCalledOnce(); }); it("does not report Hidden File Sync as ready before the main runtime is ready", () => { @@ -63,26 +60,6 @@ describe("HiddenFileSyncContext configuration-change notices", () => { expect(callPrivate<() => boolean>(hiddenFileSync, "isReady")()).toBe(false); }); - it("settles one batch of changed folders through the host notification effect", () => { - const showConfigurationChangeNotice = vi.fn(); - const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; - Object.assign(hiddenFileSync, { - dependencies: { showConfigurationChangeNotice }, - queuedNotificationFiles: new Set([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]), - }); - - callPrivate<() => void>(hiddenFileSync, "notifyConfigChange")(); - - expect(showConfigurationChangeNotice).toHaveBeenCalledWith([ - ".obsidian/plugins/alpha", - ".obsidian/plugins/beta", - ".obsidian", - ]); - expect((hiddenFileSync as unknown as { queuedNotificationFiles: Set }).queuedNotificationFiles.size).toBe( - 0 - ); - }); - it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => { const progress = { log: vi.fn(), diff --git a/src/features/HiddenFileSync/hiddenFileSyncPathAdmission.ts b/src/features/HiddenFileSync/hiddenFileSyncPathAdmission.ts new file mode 100644 index 00000000..429ade5b --- /dev/null +++ b/src/features/HiddenFileSync/hiddenFileSyncPathAdmission.ts @@ -0,0 +1,73 @@ +import type { CustomRegExpSourceList, FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { CustomRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils"; + +import { + isHiddenFileSyncPath, + matchesHiddenFileSyncPatterns, + type HiddenFileSyncPathFilters, +} from "./hiddenFileSyncPathPolicy.ts"; + +type HiddenFileSyncPathRegExpKey = "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns"; + +export type HiddenFileSyncPathAdmissionDependencies = { + getTargetPatternSource(): CustomRegExpSourceList<",">; + getIgnorePatternSource(): CustomRegExpSourceList<",">; + getFileRegExp(key: HiddenFileSyncPathRegExpKey): readonly CustomRegExp[]; + isIgnoredByIgnoreFile(path: FilePath): Promise; + ownsLocalFile(path: FilePath): boolean; +}; + +export type HiddenFileSyncPathAdmission = { + isTargetFileEligible(path: FilePath): Promise; + isTargetFile(path: FilePath): Promise; + invalidatePatternCache(): void; + dispose(): void; +}; + +class HiddenFileSyncPathAdmissionOwner implements HiddenFileSyncPathAdmission { + private readonly cacheFileRegExps = new Map(); + + constructor(private readonly dependencies: HiddenFileSyncPathAdmissionDependencies) {} + + private parseRegExpSettings(): HiddenFileSyncPathFilters { + const targetPatternSource = this.dependencies.getTargetPatternSource(); + const ignorePatternSource = this.dependencies.getIgnorePatternSource(); + const regExpKey = `${targetPatternSource}||${ignorePatternSource}`; + const cached = this.cacheFileRegExps.get(regExpKey); + if (cached) return cached; + + // Keep the inherited parser order: ignore patterns are read before target patterns. + const ignoreFilter = this.dependencies.getFileRegExp("syncInternalFilesIgnorePatterns"); + const targetFilter = this.dependencies.getFileRegExp("syncInternalFilesTargetPatterns"); + const filters: HiddenFileSyncPathFilters = { ignoreFilter, targetFilter }; + this.cacheFileRegExps.clear(); + this.cacheFileRegExps.set(regExpKey, filters); + return filters; + } + + async isTargetFileEligible(path: FilePath): Promise { + const result = matchesHiddenFileSyncPatterns(path, this.parseRegExpSettings()) && isHiddenFileSyncPath(path); + if (!result) return false; + return !(await this.dependencies.isIgnoredByIgnoreFile(path)); + } + + async isTargetFile(path: FilePath): Promise { + // Ownership is checked first so another optional-file owner cannot be filtered by this feature. + if (this.dependencies.ownsLocalFile(path) === false) return false; + return await this.isTargetFileEligible(path); + } + + invalidatePatternCache(): void { + this.cacheFileRegExps.clear(); + } + + dispose(): void { + this.cacheFileRegExps.clear(); + } +} + +export function createHiddenFileSyncPathAdmission( + dependencies: HiddenFileSyncPathAdmissionDependencies +): HiddenFileSyncPathAdmission { + return new HiddenFileSyncPathAdmissionOwner(dependencies); +} diff --git a/src/features/HiddenFileSync/hiddenFileSyncPathAdmission.unit.spec.ts b/src/features/HiddenFileSync/hiddenFileSyncPathAdmission.unit.spec.ts new file mode 100644 index 00000000..19934685 --- /dev/null +++ b/src/features/HiddenFileSync/hiddenFileSyncPathAdmission.unit.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CustomRegExpSourceList, FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { CustomRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils"; + +import { + createHiddenFileSyncPathAdmission, + type HiddenFileSyncPathAdmissionDependencies, +} from "./hiddenFileSyncPathAdmission.ts"; + +const PATH = ".obsidian/plugins/example/data.json" as FilePath; + +const pattern = (matches: (path: string) => boolean) => ({ test: vi.fn(matches) }) as unknown as CustomRegExp; + +function createDependencies( + overrides: Partial = {} +): HiddenFileSyncPathAdmissionDependencies & { + getFileRegExp: ReturnType; + isIgnoredByIgnoreFile: ReturnType; + ownsLocalFile: ReturnType; + getTargetPatternSource: ReturnType; + getIgnorePatternSource: ReturnType; +} { + const getTargetPatternSource = vi.fn(() => "target" as CustomRegExpSourceList<",">); + const getIgnorePatternSource = vi.fn(() => "ignore" as CustomRegExpSourceList<",">); + const getFileRegExp = vi.fn((key: "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns") => { + if (key == "syncInternalFilesIgnorePatterns") return []; + return []; + }); + const isIgnoredByIgnoreFile = vi.fn(async () => false); + const ownsLocalFile = vi.fn(() => true); + return { + getTargetPatternSource, + getIgnorePatternSource, + getFileRegExp, + isIgnoredByIgnoreFile, + ownsLocalFile, + ...overrides, + } as HiddenFileSyncPathAdmissionDependencies & { + getFileRegExp: ReturnType; + isIgnoredByIgnoreFile: ReturnType; + ownsLocalFile: ReturnType; + getTargetPatternSource: ReturnType; + getIgnorePatternSource: ReturnType; + }; +} + +describe("Hidden File Sync path admission", () => { + it("checks composition ownership before reading pattern settings", async () => { + const dependencies = createDependencies({ ownsLocalFile: vi.fn(() => false) }); + const admission = createHiddenFileSyncPathAdmission(dependencies); + + await expect(admission.isTargetFile(PATH)).resolves.toBe(false); + + expect(dependencies.ownsLocalFile).toHaveBeenCalledWith(PATH); + expect(dependencies.getTargetPatternSource).not.toHaveBeenCalled(); + expect(dependencies.getIgnorePatternSource).not.toHaveBeenCalled(); + expect(dependencies.getFileRegExp).not.toHaveBeenCalled(); + expect(dependencies.isIgnoredByIgnoreFile).not.toHaveBeenCalled(); + }); + + it("checks static path and pattern policy before the asynchronous ignore-file policy", async () => { + const isIgnoredByIgnoreFile = vi.fn(async () => false); + const targetFilter = [pattern(() => false)]; + const dependencies = createDependencies({ + getFileRegExp: vi.fn((key: "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns") => + key == "syncInternalFilesTargetPatterns" ? targetFilter : [] + ), + isIgnoredByIgnoreFile, + }); + const admission = createHiddenFileSyncPathAdmission(dependencies); + + await expect(admission.isTargetFile(PATH)).resolves.toBe(false); + await expect(admission.isTargetFile("notes/example.md" as FilePath)).resolves.toBe(false); + + expect(isIgnoredByIgnoreFile).not.toHaveBeenCalled(); + }); + + it("adopts target and ignore patterns while preserving their policy order", async () => { + const calls: string[] = []; + const targetFilter = [pattern(() => true)]; + const ignoreFilter = [pattern(() => true)]; + const getFileRegExp = vi.fn((key: "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns") => { + calls.push(key); + return key == "syncInternalFilesTargetPatterns" ? targetFilter : ignoreFilter; + }); + const isIgnoredByIgnoreFile = vi.fn(async () => false); + const dependencies = createDependencies({ getFileRegExp, isIgnoredByIgnoreFile }); + const admission = createHiddenFileSyncPathAdmission(dependencies); + + await expect(admission.isTargetFile(PATH)).resolves.toBe(false); + + expect(calls).toEqual(["syncInternalFilesIgnorePatterns", "syncInternalFilesTargetPatterns"]); + expect(targetFilter[0].test).not.toHaveBeenCalled(); + expect(isIgnoredByIgnoreFile).not.toHaveBeenCalled(); + }); + + it("caches parsed filters per owner and refreshes them when sources or settings change", async () => { + const targetFilter = [pattern(() => true)]; + const ignoreFilter: CustomRegExp[] = []; + let targetSource = "target" as CustomRegExpSourceList<",">; + const getTargetPatternSource = vi.fn(() => targetSource); + const getFileRegExp = vi.fn((key: "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns") => + key == "syncInternalFilesTargetPatterns" ? targetFilter : ignoreFilter + ); + const dependencies = createDependencies({ getTargetPatternSource, getFileRegExp }); + const admission = createHiddenFileSyncPathAdmission(dependencies); + + await expect(admission.isTargetFile(PATH)).resolves.toBe(true); + await expect(admission.isTargetFile(PATH)).resolves.toBe(true); + expect(getFileRegExp).toHaveBeenCalledTimes(2); + + targetSource = "changed" as CustomRegExpSourceList<",">; + await expect(admission.isTargetFile(PATH)).resolves.toBe(true); + expect(getFileRegExp).toHaveBeenCalledTimes(4); + + admission.invalidatePatternCache(); + await expect(admission.isTargetFile(PATH)).resolves.toBe(true); + expect(getFileRegExp).toHaveBeenCalledTimes(6); + }); + + it("does not share the pattern cache between owners and clears it on disposal", async () => { + const firstDependencies = createDependencies(); + const secondDependencies = createDependencies(); + const first = createHiddenFileSyncPathAdmission(firstDependencies); + const second = createHiddenFileSyncPathAdmission(secondDependencies); + + await first.isTargetFile(PATH); + await first.isTargetFile(PATH); + await second.isTargetFile(PATH); + expect(firstDependencies.getFileRegExp).toHaveBeenCalledTimes(2); + expect(secondDependencies.getFileRegExp).toHaveBeenCalledTimes(2); + + first.dispose(); + first.dispose(); + await first.isTargetFile(PATH); + expect(firstDependencies.getFileRegExp).toHaveBeenCalledTimes(4); + expect(secondDependencies.getFileRegExp).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/e2e-obsidian/scripts/customisation-sync.ts b/test/e2e-obsidian/scripts/customisation-sync.ts index 93bb02a6..e0bd3c08 100644 --- a/test/e2e-obsidian/scripts/customisation-sync.ts +++ b/test/e2e-obsidian/scripts/customisation-sync.ts @@ -242,17 +242,22 @@ async function scanCustomisations(cliBinary: string, env: NodeJS.ProcessEnv): Pr ); } -async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { +async function storeCustomisationFile( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + category: CustomisationCategory +): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", `const path=${JSON.stringify(path)};`, + `const category=${JSON.stringify(category)};`, "const core=app.plugins.plugins['obsidian-livesync'].core;", "const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.customisationSync;", "const term=core.services.setting.getDeviceAndVaultName();", "const stat=await core.storageAccess.statHidden(path);", - "const category=syncContext.getFileCategory(path);", "const result=await syncContext.storeCustomizationFiles(path,term);", "const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", "const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>doc.path);", @@ -492,15 +497,15 @@ async function main(): Promise { let session = await startConfiguredSession(context, vaultA, sourceDeviceName); const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv); console.log(`Customisation scan files: ${scanResult.files.join(", ") || "(none)"}`); - await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, configPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginManifestPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginMainPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginStylesPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginDataPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginSupplementaryPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, themeManifestPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, themeStylesPath); + await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath, "SNIPPET"); + await storeCustomisationFile(context.cliBinary, session.cliEnv, configPath, "CONFIG"); + await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginManifestPath, "PLUGIN_MAIN"); + await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginMainPath, "PLUGIN_MAIN"); + await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginStylesPath, "PLUGIN_MAIN"); + await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginDataPath, "PLUGIN_DATA"); + await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginSupplementaryPath, "PLUGIN_ETC"); + await storeCustomisationFile(context.cliBinary, session.cliEnv, themeManifestPath, "THEME"); + await storeCustomisationFile(context.cliBinary, session.cliEnv, themeStylesPath, "THEME"); const snippetEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName); const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName); const pluginEntries = await waitForCustomisationEntries( @@ -665,7 +670,7 @@ async function main(): Promise { await writeVaultFile(vaultA.path, snippetPath, snippetUpdatedContent); session = await startConfiguredSession(context, vaultA, sourceDeviceName); - await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath); + await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath, "SNIPPET"); await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName); await pushLocalChanges(context.cliBinary, session.cliEnv); await session.app.stop();