From 826413bf84e36ee889a01fa36bf3dfab72a90d4f Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 3 Sep 2026 15:43:21 +0000 Subject: [PATCH] Refactor optional file synchronisation ownership --- devs.md | 2 + ...misation_and_hidden_file_sync_ownership.md | 454 +++++++++ .../optional_file_sync_architecture.md | 196 ++++ docs/settings.md | 2 +- src/LiveSyncBaseCore.ts | 13 - src/common/messages/combinedMessages.prod.ts | 12 - src/common/messagesJson/en.json | 2 - src/common/messagesJson/es.json | 2 - src/common/messagesJson/ko.json | 2 - src/common/messagesJson/zh-tw.json | 2 - src/common/messagesYAML/en.yaml | 6 - src/common/messagesYAML/es.yaml | 6 - src/common/messagesYAML/ko.yaml | 2 - src/common/messagesYAML/zh-tw.yaml | 2 - .../CmdConfigSync.command.unit.spec.ts | 113 --- src/features/ConfigSync/PluginCombo.svelte | 42 +- src/features/ConfigSync/PluginDialogModal.ts | 22 +- .../ConfigSync/PluginDialogModal.unit.spec.ts | 46 + src/features/ConfigSync/PluginPane.svelte | 116 +-- .../ConfigSync/customisationSyncCodec.ts | 209 ++++ .../customisationSyncCodec.unit.spec.ts | 94 ++ ...tomisationSyncContext.command.unit.spec.ts | 88 ++ ...customisationSyncContext.path.unit.spec.ts | 130 +++ ...tomisationSyncContext.routing.unit.spec.ts | 112 +++ ...ationSyncContext.scan-routing.unit.spec.ts | 130 +++ ...ustomisationSyncContext.state.unit.spec.ts | 43 + ...figSync.ts => customisationSyncContext.ts} | 923 +++++++----------- .../customisationSyncContext.unit.fixture.ts | 49 + ...customisationSyncContext.view.unit.spec.ts | 149 +++ .../ConfigSync/customisationSyncPaths.ts | 113 +++ .../customisationSyncPaths.unit.spec.ts | 104 ++ .../customisationSyncUIBoundary.unit.spec.ts | 47 + .../ConfigSync/customisationSyncView.ts | 75 ++ .../CmdHiddenFileSync.unit.spec.ts | 470 --------- ...hiddenFileSyncContext.routing.unit.spec.ts | 67 ++ .../hiddenFileSyncContext.state.unit.spec.ts | 92 ++ ...enFileSync.ts => hiddenFileSyncContext.ts} | 664 ++++++------- .../hiddenFileSyncContext.unit.spec.ts | 350 +++++++ .../HiddenFileSync/hiddenFileSyncViews.ts | 38 + src/features/LiveSyncCommands.ts | 83 +- src/features/LiveSyncContext.ts | 95 ++ src/main.ts | 26 +- src/managers/StorageEventManagerObsidian.ts | 19 +- .../StorageEventManagerObsidian.unit.spec.ts | 123 +++ .../features/ModuleObsidianSettingTab.ts | 21 +- .../ModuleObsidianSettingTab.unit.spec.ts | 16 +- .../ObsidianLiveSyncSettingTab.ts | 11 +- .../SettingDialogue/PaneCustomisationSync.ts | 2 - .../features/SettingDialogue/PaneHatch.ts | 19 +- .../customisationSyncObsidianAdapter.ts | 152 +++ ...tomisationSyncObsidianAdapter.unit.spec.ts | 250 +++++ .../hiddenFileSyncObsidianAdapter.ts | 170 ++++ ...hiddenFileSyncObsidianAdapter.unit.spec.ts | 191 ++++ .../optionalFileSyncBoundary.unit.spec.ts | 70 ++ .../optionalFileSyncRouting.ts | 130 +++ .../optionalFileSyncRouting.unit.spec.ts | 165 ++++ src/serviceFeatures/useCustomisationSyncUI.ts | 149 +++ .../useCustomisationSyncUI.unit.spec.ts | 223 +++++ .../useHiddenFileSyncCommands.ts | 65 ++ .../useHiddenFileSyncCommands.unit.spec.ts | 109 +++ src/serviceFeatures/useOptionalFileSync.ts | 240 +++++ .../useOptionalFileSync.unit.spec.ts | 400 ++++++++ test/e2e-obsidian/README.md | 4 +- .../scripts/customisation-sync.ts | 183 +++- test/e2e-obsidian/scripts/dialog-mounts.ts | 66 +- .../scripts/hidden-file-snippet-sync.ts | 171 +++- .../scripts/setup-uri-workflow.ts | 8 +- 67 files changed, 6294 insertions(+), 1856 deletions(-) create mode 100644 docs/adr/2026_09_customisation_and_hidden_file_sync_ownership.md create mode 100644 docs/design_docs/optional_file_sync_architecture.md delete mode 100644 src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts create mode 100644 src/features/ConfigSync/PluginDialogModal.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncCodec.ts create mode 100644 src/features/ConfigSync/customisationSyncCodec.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncContext.command.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncContext.path.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncContext.routing.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncContext.scan-routing.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncContext.state.unit.spec.ts rename src/features/ConfigSync/{CmdConfigSync.ts => customisationSyncContext.ts} (68%) create mode 100644 src/features/ConfigSync/customisationSyncContext.unit.fixture.ts create mode 100644 src/features/ConfigSync/customisationSyncContext.view.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncPaths.ts create mode 100644 src/features/ConfigSync/customisationSyncPaths.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncUIBoundary.unit.spec.ts create mode 100644 src/features/ConfigSync/customisationSyncView.ts delete mode 100644 src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts create mode 100644 src/features/HiddenFileSync/hiddenFileSyncContext.routing.unit.spec.ts create mode 100644 src/features/HiddenFileSync/hiddenFileSyncContext.state.unit.spec.ts rename src/features/HiddenFileSync/{CmdHiddenFileSync.ts => hiddenFileSyncContext.ts} (79%) create mode 100644 src/features/HiddenFileSync/hiddenFileSyncContext.unit.spec.ts create mode 100644 src/features/HiddenFileSync/hiddenFileSyncViews.ts create mode 100644 src/features/LiveSyncContext.ts create mode 100644 src/managers/StorageEventManagerObsidian.unit.spec.ts create mode 100644 src/serviceFeatures/customisationSyncObsidianAdapter.ts create mode 100644 src/serviceFeatures/customisationSyncObsidianAdapter.unit.spec.ts create mode 100644 src/serviceFeatures/hiddenFileSyncObsidianAdapter.ts create mode 100644 src/serviceFeatures/hiddenFileSyncObsidianAdapter.unit.spec.ts create mode 100644 src/serviceFeatures/optionalFileSyncBoundary.unit.spec.ts create mode 100644 src/serviceFeatures/optionalFileSyncRouting.ts create mode 100644 src/serviceFeatures/optionalFileSyncRouting.unit.spec.ts create mode 100644 src/serviceFeatures/useCustomisationSyncUI.ts create mode 100644 src/serviceFeatures/useCustomisationSyncUI.unit.spec.ts create mode 100644 src/serviceFeatures/useHiddenFileSyncCommands.ts create mode 100644 src/serviceFeatures/useHiddenFileSyncCommands.unit.spec.ts create mode 100644 src/serviceFeatures/useOptionalFileSync.ts create mode 100644 src/serviceFeatures/useOptionalFileSync.unit.spec.ts diff --git a/devs.md b/devs.md index 7a06ad11..85ae7a7f 100644 --- a/devs.md +++ b/devs.md @@ -158,6 +158,8 @@ Use interaction-based, London School unit tests for the composition boundary. Ve See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary. +The implemented joint composition, local-owner routing, private contexts, host adapters, and focused views for Customisation Sync and Hidden File Sync are documented in [Optional-file synchronisation architecture](docs/design_docs/optional_file_sync_architecture.md). + Legacy Modules remain grouped by directory: - `core/` contains platform-independent core behaviour; 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 new file mode 100644 index 00000000..0233b7b0 --- /dev/null +++ b/docs/adr/2026_09_customisation_and_hidden_file_sync_ownership.md @@ -0,0 +1,454 @@ +--- +date: 2026-09-03 +commonlib-version: "0.1.21" +self-hosted-livesync-version: "1.0.24" +status: accepted +--- + +# Architectural Decision Record: Customisation Sync and Hidden File Sync ownership + +## Status + +Accepted and implemented. The completed structural stages +extract the Customisation Sync path and codec operations, characterise the +current shared routing behaviour, remove the production UI dependency cycle, +move host presentation and Hidden File Sync commands into serviceFeatures, +give one optional-file serviceFeature ownership of both runtime contexts and +all Service handler registration, and replace both runtimes' complete-core +dependencies with narrow host adapters. A pure local-path policy now selects +one writer for raw events, scheduled scans, and automatic database-to-local +reflection. The constructor-name add-on lookup and the `ConfigSync` and +`HiddenFileSync` add-on identities have been retired. + +The Customisation Sync private context owns its catalogue, progress, manifest, +queue, and periodic state. The Hidden File Sync private context owns its +processed-state caches, reconciliation and conflict queues, activity counts, +and periodic state. 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). + +## Context + +Customisation Sync and Hidden File Sync are supported, advanced, opt-in +features with different data and interaction contracts: + +- Customisation Sync stores device-scoped snapshots in the `ix:` namespace. + It supports grouped V1 documents and per-file V2 documents, presents a + catalogue, and applies selected remote state only after an explicit action. +- Hidden File Sync stores one mirrored hidden file in each `i:` Metadata + document. It scans and reconciles local and database state, applies eligible + remote changes automatically, and maintains device-local processed-state + records for offline and conflict handling. + +In the baseline implementation, the `ConfigSync` and `HiddenFileSync` add-ons +each combined domain operations, mutable state, Service handler registration, +lifecycle work, settings policy, commands, and Obsidian presentation. Both +received the complete core through `LiveSyncCommands`, and both registered +handlers for optional file events, conflicts, settings, application lifecycle, +database initialisation, and replication. + +Some of those Commonlib handlers short-circuit at the first successful or +non-empty result. The construction order of `ConfigSync` before +`HiddenFileSync` is therefore observable behaviour. The Obsidian storage-event +manager also asks the Vault Service whether a configuration-directory file is +an extra target, while the corresponding predicate is currently registered by +`HiddenFileSync`. Local file ownership is consequently distributed across the +storage-event manager, both add-ons, settings entries, target and ignore +patterns, and handler registration order. + +In the baseline implementation, user-interface, maintenance, and +real-Obsidian E2E consumers located the concrete add-ons through `getAddOn()`. +Removing either add-on before migrating those consumers would therefore have +combined an ownership change, a lifecycle change, and a consumer migration in +one step. + +The baseline Customisation Sync presentation also formed a runtime import +cycle: `ConfigSync` imported its Obsidian dialogue, the dialogue imported its +Svelte pane, and the pane imported `ConfigSync` and `HiddenFileSync` to locate +both add-ons. The child row component imported `ConfigSync` again. The settings +tab also imported `main.ts` as a runtime binding even though it used the +plug-in class only as a type. Bundling survived because those bindings were +read after module evaluation, but that timing was not an architectural +contract. Presentation therefore had to move off the concrete add-ons before +the domain runtimes could be extracted. + +## Decision drivers + +The redesign must: + +1. preserve `ix:` and `i:` data, including V1 and V2 Customisation Sync data; +2. give each local path at most one synchronisation owner; +3. remove handler registration order as the source of path ownership; +4. preserve explicit Customisation Sync application and automatic Hidden File + Sync reflection as separate behaviours; +5. make mutable state and resource disposal ownership explicit; +6. keep Obsidian presentation outside host-neutral operations; +7. migrate existing add-on consumers to focused views before retiring add-on + identity; and +8. permit each migration stage to be verified independently. + +## Decision + +### Retain two domain runtimes + +Customisation Sync and Hidden File Sync will remain separate domain runtimes. +They will not be implemented as modes of one generic file-sync engine. + +The Customisation Sync runtime will own: + +- V1 and V2 codecs and document-path compatibility; +- the device catalogue and snapshot repository; +- scanning, migration, logical deletion, comparison, and explicit apply + operations; and +- the state required to serialise and report those operations. + +The Hidden File Sync runtime will own: + +- the device-local processed-state records; +- one-file storage and database transfer, including exact-revision repair; +- start-up, offline, periodic, and pre-replication reconciliation; +- conflict queues and automatic or interactive JSON resolution; and +- the state required to serialise and report those operations. + +Neither runtime will accept `LiveSyncBaseCore` as its domain dependency. +Operations will receive narrow Services, ServiceModules, settings projections, +and host effects. + +### Compose one owner for overlapping handlers + +One joint serviceFeature constructs both private runtime contexts and owns +registration into the overlapping optional-file, conflict, target, settings, +database, replication, and application-lifecycle handlers. A single +optional-file callback selects its local owner before invoking either context, +and a single conflict callback dispatches the disjoint `ps:`, `ix:`, and `i:` +namespaces. Callback order and add-on construction order no longer select the +local file owner. Config-before-Hidden order remains explicit only for shared +lifecycle handlers whose side effects still require compatibility. + +The composition feature will remain small. Codecs, path functions, +repositories, state transitions, and transfer operations are ordinary modules +or private contexts rather than separate serviceFeatures. No new +ServiceModule is justified while those capabilities have one composition +owner and no independent long-lived consumers. + +Internally, operations will use a typed settlement which distinguishes at +least handled, skipped, and failed work. The composition boundary will adapt +that settlement to the existing Commonlib Boolean or first-result handler +contracts. Existing short-circuit and failure behaviour must be characterised +before an adapter changes it. + +### Make local path ownership explicit + +A pure routing policy classifies a local path as owned by Customisation +Sync, owned by Hidden File Sync, or ignored with a reason. Its inputs will +include: + +- the Obsidian configuration directory; +- whether each feature is enabled and ready; +- the Customisation Sync category and extended mode; +- Hidden File Sync target and ignore patterns; and +- the ignore-file result where that asynchronous policy applies. + +The maintained ownership intent is: + +| Path and mode | Local owner | +| ------------------------------------------------------------ | ------------------ | +| Recognised Customisation Sync path in Selective mode | Customisation Sync | +| Recognised Customisation Sync path in Flagged Selective mode | Customisation Sync | +| Recognised Customisation Sync path in Automatic mode | Hidden File Sync | +| Recognised Customisation Sync path in Ignore mode | Neither feature | +| Other eligible hidden path | Hidden File Sync | +| Excluded, ignored, disabled, or ordinary Vault path | Neither feature | + +This table is now used for raw-event dispatch and is injected into both +contexts for scheduled or database-driven local work. Default and persisted +Selective entries therefore have the same Customisation Sync owner. Hidden +File Sync target patterns and ignore-file results are evaluated only after the +policy selects Hidden File Sync, so they cannot prevent a Selective +Customisation Sync event. Automatic mode has no local owner while Hidden File +Sync is disabled, and Ignore mode has no local owner in either context. + +Commonlib's ordinary event queue rejects dot paths while Hidden File Sync is +disabled. The Obsidian raw-event boundary therefore dispatches an event which +the policy assigns to Customisation Sync directly after retaining the queue's +configuration, suspension, and modification-time gates. Events handled while +Hidden File Sync is enabled continue through the ordinary queue. + +This is a local-write routing table, not a database-document acceptance table. +Existing `ix:` documents must remain recognisable after a local mode change, +and they must not fall through to ordinary Vault reflection. Existing `i:` +documents retain their own namespace and selection rules. The implementation +therefore keeps namespace-specific database handlers separate from the +local-path policy rather than exposing one general `isTargetPath()` predicate. +A narrower document decision controls whether a local Customisation Sync scan +may mutate an existing `ix:` document; it does not control whether that +document is recognised or consumed. + +### Use private contexts and focused views + +Each mutable object will have one named owner. Catalogue stores, manifest +caches, processed-state records, recent-event records, locks, semaphores, +queues, periodic processors, Notices, and event subscriptions must be created +and disposed by their feature context or a focused resource owner. + +The joint composition may return several views backed by those contexts: + +- a Customisation Sync catalogue and operation view for its dialogue; +- a Hidden File Sync initialisation view for settings workflows; and +- a Hidden File Sync repair view for the Hatch pane. + +Several views over one context do not create several owners. Views expose +stable application data and operations rather than PouchDB entries, queue +objects, mutable settings records, or the complete core. + +Obsidian commands, ribbon actions, dialogues, Notices, plug-in reloads, and +restart scheduling will remain in host-owned composition. The UI will receive +focused views instead of locating `ConfigSync` or `HiddenFileSync` and calling +one add-on from the other. + +The Customisation Sync dialogue has its own host-owned presentation +serviceFeature. It owns command, ribbon, event subscription, dialogue reuse, +and dialogue disposal, and consumes the catalogue and Hidden File Sync +initialisation views. Neither domain runtime imports its Obsidian dialogue or +Svelte components. This presentation feature is separate from the joint +synchronisation owner because it registers host UI rather than overlapping +file, conflict, or replication handlers. + +Hidden File Sync commands and their setting-change event subscription are +owned by a second host serviceFeature. It consumes only the command view and +releases its lifecycle and event registrations during application unload. + +### Retire compatibility façades after consumer migration + +The concrete `ConfigSync` and `HiddenFileSync` add-on identities were retained +only until their consumers and handler ownership had migrated. They have now +been replaced by private `CustomisationSyncContext` and +`HiddenFileSyncContext` instances constructed solely by the joint +serviceFeature. `LiveSyncBaseCore.getAddOn()` and its constructor-name lookup +have been removed. + +Production code consumes focused views and does not use the broad context +surface. Maintained real-Obsidian E2E workflows use an explicitly internal +test view exposed by the composed feature. This is a transitional test seam, +not a production service locator, and it should be narrowed as those workflows +move to public operations or commands. + +The retirement was gated on: + +- production UI and maintenance consumers no longer use `getAddOn()` for these + features; +- maintained E2E helpers use commands or an explicit test view; +- no handler depending on add-on construction order; and +- unload and replacement tests prove that every owned processor, + subscription, queue, dialogue, and Notice is released. + +## Persisted compatibility + +This decision does not authorise a data migration. + +- `ix:` remains the Customisation Sync Metadata namespace. +- `i:` remains the Hidden File Sync Metadata namespace. +- Metadata continues to reference Chunks rather than embedding raw file + content as an ordinary Metadata field. +- V1 grouped Customisation Sync documents remain readable. +- V2 per-file Customisation Sync paths and the existing V1-to-V2 migration + remain readable and idempotent. +- Device and Vault terms, logical deletions, revision identifiers, and current + path derivation remain compatibility inputs. +- Hidden File Sync processed-state keys remain device-local state unless a + separately tested migration is introduced. + +Pure extraction must preserve exact case, depth, prefix, delimiter, and +fallback behaviour even where a later correction appears desirable. + +## Lifecycle and failure boundaries + +Feature composition will occur after required Services and ServiceModules +exist and before lifecycle-driven work begins. Command and Obsidian UI +registration will occur at the readiness point required by the host. + +The owner will: + +- start periodic work only while its feature is enabled, ready, and resumed; +- coalesce or serialise work within the feature instance rather than through + process-global string keys where practical; +- stop admission before disposing queues or processors; +- unsubscribe every registered local event listener; and +- close owned dialogues and Notices during unload. + +A failed operation must not be converted to handled success merely to stop a +later handler, unless a characterisation test proves that the existing +contract deliberately consumes that failure. Such compatibility adaptations +must be visible at the composition boundary. + +## Migration and verification sequence + +### Stage 1: characterise pure and routing contracts — implemented + +- Cover current Customisation Sync category and V1/V2 document-path rules. +- Cover codec round trips, legacy JSON and YAML fallbacks, and migration + sentinels before extracting the codec. +- Record the effective Selective, Automatic, Ignore, and Flagged Selective + routing matrix, including feature-disabled and pattern-excluded cases. +- Record handler registration, short-circuiting, and teardown behaviour. + +### Stage 2: extract pure operations and the presentation boundary — implemented + +- Move Customisation Sync path and document-key functions behind the existing + façade methods. +- Move the V1/V2 codec with its hash and YAML dependencies made explicit. +- Inject catalogue, initialisation, and repair views into production UI. +- Move Customisation Sync command, ribbon, event subscription, and dialogue + lifetime into a host-owned presentation serviceFeature. +- Prohibit presentation imports of either concrete add-on or the application + core, and prohibit the runtime from importing its dialogue. + +### Stage 3: make routing ownership explicit — implemented + +- Introduce the pure routing policy, initially adapting both runtime contexts + to it at the joint composition boundary. +- Preserve the characterised legacy routing until each discrepancy has its own + behavioural decision and regression test. + +One optional-file handler now dispatches exactly one selected context, and one +namespace router handles optional conflicts. Both contexts receive the same +static ownership projections for raw events and scans. The final raw-event +decision additionally includes lifecycle readiness, Hidden File Sync patterns, +and the asynchronous ignore-file result. Handler failure does not fall through +to the non-owner. + +### Stage 4: extract the Customisation Sync runtime — implemented + +- Move catalogue, snapshot repository, scan, apply, compare, delete, and + migration operations into a private context. +- Replace module-global mutable state with context-owned state. +- Replace the complete-core dependency with narrow dependencies. + +The private context, path module, codec module, focused presentation view, and +resource teardown are implemented. Catalogue, enumeration, migration, scan, +and manifest state is owned per context instance. 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 + +- Move processed-state, transfer, reconciliation, conflict, and notification + operations into explicit owners. +- Preserve exact-revision repair and current initialisation directions. +- Replace the complete-core dependency with narrow dependencies. + +The private context, focused initialisation, repair, and command views, +host-owned command registration, and processor, cache, subscription, and +Notice teardown are implemented. The context owns transfer, reconciliation, +conflict, and processed-state behaviour through narrow live dependencies. +An Obsidian adapter owns JSON conflict dialogues, progress presentation, +grouped Notices, plug-in reload, restart scheduling, Vault enumeration, and +compatibility activity publication. + +### Stage 6: move synchronisation composition — implemented + +- Register the overlapping Service handlers once through the joint + serviceFeature. +- Preserve the characterised lifecycle callback order and Commonlib + aggregation semantics through focused tests. + +### Stage 7: retire the façades — implemented + +- Remove add-on identity and constructor-order dependencies. +- Migrate maintained E2E workflows to the explicit feature test boundary. +- Remove constructor-name service lookup from the core. + +The implemented topology is documented separately from this migration record +in [Optional-file synchronisation architecture](../design_docs/optional_file_sync_architecture.md). + +Each stage will run focused unit tests. Changes to Customisation Sync and +Hidden File Sync will run their respective real-Obsidian E2E workflows. The +composition switch will additionally require a mixed-ownership workflow which +proves that one local path is never written by both features. + +## Non-goals + +This migration does not: + +- merge the two persisted namespaces or sync models; +- move implementation into Commonlib before another maintained host needs the + capability; +- change feature maturity, default enablement, setup, or initialisation; +- replace current conflict policy with a generic conflict engine; +- rename existing setting keys, command identifiers, or user-interface labels; + or +- correct unrelated suspicious behaviour while extracting code. + +## Characterisation gates + +Stage 3 resolved the routing-specific gates with focused regressions: + +- an unrecognised eligible hidden path is owned by Hidden File Sync; +- default and persisted Selective entries are owned by Customisation Sync; +- raw Customisation Sync remains available while Hidden File Sync is disabled; +- Hidden File Sync patterns and ignore-file results gate only its selected + paths; +- Automatic mode without an enabled and ready Hidden File Sync owner is + ignored rather than falling back to Customisation Sync; +- a selected handler which skips or fails does not fall through to the other + context; and +- Customisation Sync raw admission now invokes the readiness predicate and + rejects unrecognised or non-owned paths. + +The extraction preserves the existing V1 and V2 plug-in application paths, +exact-revision repair, initial cache conditions, and the selected Hidden File +Sync database-processing settlement. These remain compatibility gates for any +future behavioural change. Any defect correction requires its own failing +regression. + +## Alternatives rejected + +### Convert each extracted file into a serviceFeature + +This would reproduce distributed registration and lifetime ownership under +more function names. Pure operations and one private context are the narrower +boundary. + +### Merge both features into one generic hidden-file engine + +This would obscure the explicit-apply snapshot contract, the automatic mirror +contract, and their incompatible persistence and conflict semantics. + +### Remove both add-ons before characterisation and consumer migration + +This would change identity, ordering, lifecycle, UI, maintenance, and E2E +boundaries simultaneously. Retaining identity through the earlier migration +stages gave each structural change a smaller failure surface. + +### Move the runtimes to Commonlib first + +There is no second maintained consumer for the complete feature behaviour at +present. Moving the monoliths across the package boundary would enlarge the +migration without first establishing narrow dependencies. + +## Consequences + +- Local path ownership can become explicit at one composition boundary. +- Persisted formats and supported user workflows remain stable during the + migration. +- The composition root gains several focused views but does not gain another + runtime service locator. +- The legacy add-on identity and constructor-name lookup are removed. +- The two contexts remain sizeable because each owns one cohesive persisted + synchronisation model, but their dependency surfaces are explicit and do + not include the complete core. Further extraction should follow a concrete + behavioural boundary rather than create additional serviceFeatures for + private operations. + +## References + +- [Feature maturity for 1.0](2026_07_feature_maturity_for_1_0.md) +- [Service feature and legacy Module boundaries](../design_docs/service_feature_and_legacy_module_boundaries.md) +- [Optional-file synchronisation architecture](../design_docs/optional_file_sync_architecture.md) +- [Hidden File Sync guide](../tips/hidden-file-sync.md) +- [Settings reference](../settings.md#6-customisation-sync-advanced) +- [Development guide](../../devs.md#service-composition-and-legacy-modules) diff --git a/docs/design_docs/optional_file_sync_architecture.md b/docs/design_docs/optional_file_sync_architecture.md new file mode 100644 index 00000000..239a104e --- /dev/null +++ b/docs/design_docs/optional_file_sync_architecture.md @@ -0,0 +1,196 @@ +--- +date: 2026-09-03 +commonlib-version: "0.1.21" +self-hosted-livesync-version: "1.0.24" +status: accepted +--- + +# Optional-file synchronisation architecture + +## Purpose and scope + +This document describes the implemented ownership and composition of +Customisation Sync and Hidden File Sync. These features share host event +boundaries, but retain separate persisted data, state, and application +behaviour. The corresponding decision history and migration constraints are +recorded in the +[Customisation Sync and Hidden File Sync ownership ADR](../adr/2026_09_customisation_and_hidden_file_sync_ownership.md). + +The implementation is private application architecture. It does not define a +third-party extension API, a runtime feature registry, or a generic hidden-file +engine. + +## Topology + +```text +Obsidian composition (`main.ts`) + | + +--> `useOptionalFileSync` + | | + | +--> one Service-handler registration owner + | +--> pure local-path and document routing policy + | | + | +--> `CustomisationSyncContext` + | | ^ + | | +-- narrow dependencies from + | | `customisationSyncObsidianAdapter` + | | + | +--> `HiddenFileSyncContext` + | ^ + | +-- narrow dependencies from + | `hiddenFileSyncObsidianAdapter` + | + +--> `useCustomisationSyncUI` + | +-- catalogue and operation view + | +-- Hidden File Sync initialisation view + | + +--> `useHiddenFileSyncCommands` + +-- Hidden File Sync command view + +Hatch settings consumer + +-- Hidden File Sync exact-revision repair view +``` + +`useOptionalFileSync` is the only owner of the overlapping synchronisation +registrations. The presentation serviceFeatures receive focused views from +that composition; they do not locate either concrete context through the +application core. + +## Ownership + +| 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, catalogue, manifest cache, scan queues, migration progress, snapshot storage and application, and periodic scan state. | Obsidian dialogues, plug-in lifecycle APIs, ribbon actions, or handler registration. | +| `HiddenFileSyncContext` | The `i:` transfer rules, device-local processed-state caches, reconciliation, exact-revision repair, conflict queues, notification batching, activity counts, and periodic scan state. | Obsidian conflict dialogues, grouped Notices, plug-in lifecycle APIs, or handler registration. | +| 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. | +| `useHiddenFileSyncCommands` | Hidden File Sync command registration, setting-change subscription, and their unload teardown. | Synchronisation state or command implementation. | + +The two domain contexts remain sizeable because they each own one cohesive +persisted synchronisation model. Their private operations are not additional +serviceFeatures: they do not independently register host integration or have +separate application lifetimes. Extract a further ordinary module or focused +state owner when a concrete invariant, replacement lifecycle, or independently +testable operation justifies that boundary. + +## Routing and handler contracts + +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. + +The maintained local ownership is: + +| Path mode | Owner | +| --- | --- | +| Customisation path in Selective or Flagged Selective mode | Customisation Sync | +| Customisation path in Automatic mode, with Hidden File Sync enabled | Hidden File Sync | +| Customisation path in Ignore mode | Neither context | +| Other eligible hidden path | Hidden File Sync | +| Disabled, excluded, ignored, or ordinary Vault path | Neither context | + +Local ownership is distinct from persisted-document recognition. The +composition dispatches `ps:` and `ix:` conflict documents to Customisation +Sync and `i:` conflict documents to Hidden File Sync. Existing documents remain +recognisable after a local mode changes. + +The composition adapts the contexts to the existing Commonlib handler +contracts: + +- raw optional-file events are offered to exactly one selected owner; +- a selected handler which skips or fails does not fall through to the other + context; +- Customisation Sync receives virtual Customisation documents; +- Hidden File Sync receives optional synchronisation results for `i:` + documents; +- shared lifecycle handlers retain Customisation-before-Hidden order; and +- the Vault extra-target handler returns the final routed ownership decision. + +## Domain dependency boundaries + +Neither context imports `main.ts`, accepts `LiveSyncCore`, extends +`LiveSyncContext`, or reaches through `this.core`, `this.services`, or +`this.app`. + +Each context receives live getter projections for settings and the local +database. This is important because both can be replaced during settings or +database lifecycle work. Stable ServiceModules, such as storage access and +database file access, are supplied as focused method projections. Host effects +are named individually in the dependency contract. + +The Hidden File Sync exact-revision boundary uses only +`fetchEntryMeta`, `getConflictedRevs`, `fetchEntryFromMeta`, and +`storeWithBaseRevision`. A selected revision is checked against the current +winner and conflict leaves before it can be applied or extended. The repair +view therefore does not expose the complete database file-access module. + +Obsidian-specific adapters can import the application core as a type and read +the required Services and ServiceModules at composition. The resulting +dependency objects are the only bridge from either domain context to Obsidian +presentation and host lifecycle APIs. + +## Views and consumers + +One context can back several focused views without creating several state +owners: + +- `CustomisationSyncDialogView` supplies catalogue and explicit application + operations to the Customisation Sync dialogue; +- `HiddenFileSyncInitialisationView` supplies the initialisation directions + needed by that dialogue; +- `HiddenFileSyncCommandView` supplies availability and scan operations to + host-owned commands; and +- `HiddenFileSyncRepairView` supplies local inspection and exact-revision + operations to the Hatch pane. + +Production consumers receive these views directly. `OptionalFileSyncFeature` +also exposes an explicitly internal `testing` view for maintained real-Obsidian +contract tests. That test seam is not a production service locator and should +not be used by application features. + +## 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 queues, caches, semaphores, activity state, and periodic processor. + +On application unload, `useOptionalFileSync` first removes every Service +handler registration. It then disposes Customisation Sync followed by Hidden +File Sync, preserving the former compatibility order. Disposal disables +periodic admission, terminates queues, clears transient caches and pending +sets, cancels scheduled notification work, resets compatibility telemetry, and +hides owned Notices. The two presentation serviceFeatures independently remove +their commands, event subscriptions, ribbon state, and dialogue instances. + +## Persisted compatibility + +This architecture does not migrate persisted data: + +- Customisation Sync remains in the `ix:` namespace and continues to read V1 + grouped and V2 per-file data; +- Hidden File Sync remains in the `i:` namespace; +- file content remains in Chunks referenced by Metadata rather than being + embedded as ordinary Metadata content; +- exact PouchDB revision identifiers remain part of repair and conflict + operations; and +- Hidden File Sync processed-state keys remain device-local key-value data. + +Any change to these contracts requires separate compatibility tests and, when +appropriate, a migration decision. + +## Verification boundaries + +Focused unit tests cover routing, handler aggregation, context state +isolation, teardown, initial cache selection, exact-revision repair, conflict +dialogue adaptation, grouped Notices, and compatibility activity publication. +The boundary test prevents either domain context from regaining core or +Obsidian dependencies. + +Changes to local routing or transfer require the maintained Customisation Sync +and Hidden File Sync real-Obsidian workflows. A composition change also +requires the mixed-ownership case which proves that one local path is not +written by both contexts. diff --git a/docs/settings.md b/docs/settings.md index feddbb73..266974ba 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -721,7 +721,7 @@ When a saved setting changes whether a normal file can be reflected, LiveSync re ## 6. Customisation sync (Advanced) -Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files. +Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for configuration files, themes, snippets, and plug-in main, data, and supplementary files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files. ### 1. Customisation Sync diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index edb925bc..0f12e082 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -62,18 +62,6 @@ export class LiveSyncBaseCore< this.services.appLifecycle.onUnload.addHandler(() => Promise.resolve(addOn.onunload()).then(() => true)); } - /** - * Get an add-on by its class name. Returns undefined if not found. - * @param cls - * @returns - */ - getAddOn(cls: string) { - for (const addon of this.addOns) { - if (addon.constructor.name == cls) return addon as T; - } - return undefined; - } - constructor( serviceHub: InjectableServiceHub, serviceModuleInitialiser: ( @@ -304,5 +292,4 @@ export class LiveSyncBaseCore< export interface IMinimumLiveSyncCommands { onunload(): void; onload(): void | Promise; - constructor: { name: string }; } diff --git a/src/common/messages/combinedMessages.prod.ts b/src/common/messages/combinedMessages.prod.ts index 27d97349..0550adab 100644 --- a/src/common/messages/combinedMessages.prod.ts +++ b/src/common/messages/combinedMessages.prod.ts @@ -363,18 +363,6 @@ export const allMessages: Readonly ({ - addIcon: vi.fn(), - diff_match_patch: class DiffMatchPatch {}, - normalizePath: vi.fn((path: string) => path), - parseYaml: vi.fn(), - Platform: {}, -})); -vi.mock("./PluginDialogModal.ts", () => ({ - PluginDialogModal: class PluginDialogModal {}, -})); -vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ - JsonResolveModal: class JsonResolveModal {}, -})); -vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({ - ConflictResolveModal: class ConflictResolveModal {}, -})); -vi.mock("@/features/LiveSyncCommands.ts", () => ({ - LiveSyncCommands: class LiveSyncCommands { - core!: { services: unknown }; - get services() { - return this.core.services; - } - }, -})); -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/events.ts", () => ({ - EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync", - eventHub: { - onEvent: vi.fn(), - }, -})); -vi.mock("@/common/translation", () => ({ - $msg: vi.fn((message: string) => message), -})); -vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ - getObsidianCommunityPluginManager: vi.fn(), -})); - -import { cancelTask } from "@/common/utils.ts"; -import { ConfigSync } from "./CmdConfigSync"; - -describe("ConfigSync commands", () => { - it("shows the Customisation Sync command only whilst the feature is enabled", () => { - const commands: Array<{ - id: string; - checkCallback?: (checking: boolean) => boolean | void; - }> = []; - const settings = { - usePluginSync: false, - }; - const showPluginSyncModal = vi.fn(); - const configSync = Object.create(ConfigSync.prototype) as ConfigSync; - Object.assign(configSync, { - core: { - settings, - services: { - API: { - addCommand: vi.fn((command) => commands.push(command)), - }, - }, - }, - addRibbonIcon: vi.fn(() => ({ - addClass: vi.fn(), - })), - showPluginSyncModal, - }); - - configSync.onload(); - - const command = commands.find(({ id }) => id === "livesync-plugin-dialog-ex"); - expect(command?.checkCallback?.(true)).toBe(false); - - settings.usePluginSync = true; - expect(command?.checkCallback?.(true)).toBe(true); - expect(command?.checkCallback?.(false)).toBe(true); - expect(showPluginSyncModal).toHaveBeenCalledOnce(); - }); - - it("cancels the pending configuration Notice before releasing its owned UI", () => { - const notices = { hide: vi.fn() }; - const periodicPluginSweepProcessor = { disable: vi.fn() }; - const configSync = Object.create(ConfigSync.prototype) as ConfigSync; - Object.assign(configSync, { - core: { - services: { - context: { notices }, - }, - }, - periodicPluginSweepProcessor, - }); - - configSync.onunload(); - - expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration"); - expect(notices.hide).toHaveBeenCalledWith("config-sync:updated-configuration"); - expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/features/ConfigSync/PluginCombo.svelte b/src/features/ConfigSync/PluginCombo.svelte index 7daba34a..4e389864 100644 --- a/src/features/ConfigSync/PluginCombo.svelte +++ b/src/features/ConfigSync/PluginCombo.svelte @@ -1,15 +1,9 @@ diff --git a/src/features/ConfigSync/PluginDialogModal.ts b/src/features/ConfigSync/PluginDialogModal.ts index f5dc12ef..27cf69cf 100644 --- a/src/features/ConfigSync/PluginDialogModal.ts +++ b/src/features/ConfigSync/PluginDialogModal.ts @@ -1,17 +1,24 @@ import { mount, unmount } from "svelte"; -import { App, Modal } from "@/deps.ts"; -import ObsidianLiveSyncPlugin from "@/main.ts"; +import { type App, Modal } from "@/deps.ts"; +import type { HiddenFileSyncInitialisationView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts"; +import type { CustomisationSyncDialogView } from "./customisationSyncView.ts"; import PluginPane from "./PluginPane.svelte"; export class PluginDialogModal extends Modal { - plugin: ObsidianLiveSyncPlugin; + customisationSync: CustomisationSyncDialogView; + hiddenFileSync: HiddenFileSyncInitialisationView; component: ReturnType | undefined; isOpened() { return this.component != undefined; } - constructor(app: App, plugin: ObsidianLiveSyncPlugin) { + constructor( + app: App, + customisationSync: CustomisationSyncDialogView, + hiddenFileSync: HiddenFileSyncInitialisationView + ) { super(app); - this.plugin = plugin; + this.customisationSync = customisationSync; + this.hiddenFileSync = hiddenFileSync; } override onOpen() { @@ -25,7 +32,10 @@ export class PluginDialogModal extends Modal { if (!this.component) { this.component = mount(PluginPane, { target: contentEl, - props: { plugin: this.plugin, core: this.plugin.core }, + props: { + customisationSync: this.customisationSync, + hiddenFileSync: this.hiddenFileSync, + }, }); } } diff --git a/src/features/ConfigSync/PluginDialogModal.unit.spec.ts b/src/features/ConfigSync/PluginDialogModal.unit.spec.ts new file mode 100644 index 00000000..3e1d65ce --- /dev/null +++ b/src/features/ConfigSync/PluginDialogModal.unit.spec.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const svelteMocks = vi.hoisted(() => ({ + mount: vi.fn(), + unmount: vi.fn(), +})); + +vi.mock("svelte", () => svelteMocks); +vi.mock("@/deps.ts", () => ({ + Modal: class Modal { + contentEl = { setCssStyles: vi.fn() }; + titleEl = { setText: vi.fn() }; + }, +})); +vi.mock("./PluginPane.svelte", () => ({ default: "PluginPane" })); + +import { PluginDialogModal } from "./PluginDialogModal.ts"; + +describe("PluginDialogModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("mounts the focused views once and releases the component on close", () => { + const component = { component: "customisation-sync" }; + const customisationSync = { catalogue: {} }; + const hiddenFileSync = { initialiseInternalFileSync: vi.fn() }; + svelteMocks.mount.mockReturnValue(component); + + const modal = new PluginDialogModal({} as never, customisationSync as never, hiddenFileSync); + modal.onOpen(); + modal.onOpen(); + + expect(svelteMocks.mount).toHaveBeenCalledOnce(); + expect(svelteMocks.mount).toHaveBeenCalledWith("PluginPane", { + target: modal.contentEl, + props: { customisationSync, hiddenFileSync }, + }); + expect(modal.isOpened()).toBe(true); + + modal.onClose(); + + expect(svelteMocks.unmount).toHaveBeenCalledWith(component); + expect(modal.isOpened()).toBe(false); + }); +}); diff --git a/src/features/ConfigSync/PluginPane.svelte b/src/features/ConfigSync/PluginPane.svelte index a6be2a6e..b9d008d0 100644 --- a/src/features/ConfigSync/PluginPane.svelte +++ b/src/features/ConfigSync/PluginPane.svelte @@ -1,14 +1,6 @@
@@ -357,8 +311,10 @@
- {#if loading || $pluginV2Progress !== 0} - {translateMessage("Updating list...")}{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`} + {#if loading || $migrationProgress !== 0} + {translateMessage("Updating list...")}{$migrationProgress == 0 ? "" : ` (${$migrationProgress})`} {/if}
diff --git a/src/features/ConfigSync/customisationSyncCodec.ts b/src/features/ConfigSync/customisationSyncCodec.ts new file mode 100644 index 00000000..d4af39d3 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncCodec.ts @@ -0,0 +1,209 @@ +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +const FIELD_DELIMITER = "\u200b"; +const LINE_DELIMITER = "\n"; + +export type PluginDataExFile = { + filename: string; + data: string[]; + mtime: number; + size: number; + version?: string; + hash?: string; + displayName?: string; +}; + +export type PluginDataEx = { + documentPath?: FilePathWithPrefix; + category: string; + name: string; + displayName?: string; + term: string; + files: PluginDataExFile[]; + version?: string; + mtime: number; +}; + +export type CustomisationSyncCodecDependencies = { + digestHash(data: string[]): string; + parseYaml(source: string): unknown; +}; + +function splitWithDelimiters(sources: string[]): string[] { + const result: string[] = []; + for (const str of sources) { + let startIndex = 0; + const maxLen = str.length; + let i = -1; + let fieldIndex; + let lineIndex; + do { + fieldIndex = str.indexOf(FIELD_DELIMITER, startIndex); + lineIndex = str.indexOf(LINE_DELIMITER, startIndex); + if (fieldIndex == -1 && lineIndex == -1) { + break; + } + if (fieldIndex == -1) { + i = lineIndex; + } else if (lineIndex == -1) { + i = fieldIndex; + } else { + i = fieldIndex < lineIndex ? fieldIndex : lineIndex; + } + result.push(str.slice(startIndex, i + 1)); + startIndex = i + 1; + } while (i < maxLen); + if (startIndex < maxLen) { + result.push(str.slice(startIndex)); + } + } + + // Preserve the legacy trailing-empty-chunk behaviour. + if (sources[sources.length - 1] == "") { + result.push(""); + } + + return result; +} + +function getTokenizer(source: string[]) { + const sources = splitWithDelimiters(source); + sources[0] = sources[0].substring(1); + let pos = 0; + let lineRunOut = false; + return { + next(): string { + if (lineRunOut) { + return ""; + } + if (pos >= sources.length) { + return ""; + } + const item = sources[pos]; + if (!item.endsWith(LINE_DELIMITER)) { + pos++; + } else { + lineRunOut = true; + } + if (item.endsWith(FIELD_DELIMITER) || item.endsWith(LINE_DELIMITER)) { + return item.substring(0, item.length - 1); + } + return item + this.next(); + }, + nextLine() { + if (lineRunOut) { + pos++; + } else { + while (!sources[pos].endsWith(LINE_DELIMITER)) { + pos++; + if (pos >= sources.length) break; + } + pos++; + } + lineRunOut = false; + }, + }; +} + +function deserializeCustomFormat(source: string[]): PluginDataEx { + const tokens = getTokenizer(source); + const category = tokens.next(); + const name = tokens.next(); + const term = tokens.next(); + tokens.nextLine(); + const version = tokens.next(); + tokens.nextLine(); + const mtime = Number(tokens.next()); + tokens.nextLine(); + const result: PluginDataEx = { + category, + name, + term, + version, + mtime, + files: [], + }; + let filename = ""; + do { + filename = tokens.next(); + if (!filename) break; + const displayName = tokens.next(); + const fileVersion = tokens.next(); + tokens.nextLine(); + const fileMtime = Number(tokens.next()); + const size = Number(tokens.next()); + const hash = tokens.next(); + tokens.nextLine(); + const data: string[] = []; + let piece = ""; + do { + piece = tokens.next(); + if (piece == "") break; + data.push(piece); + } while (piece != ""); + result.files.push({ + filename, + displayName, + version: fileVersion, + mtime: fileMtime, + size, + data, + hash, + }); + tokens.nextLine(); + } while (filename); + return result; +} + +export function createCustomisationSyncCodec(dependencies: CustomisationSyncCodecDependencies) { + function serialize(data: PluginDataEx): string { + // Group fields with similar entropy around newlines to retain the existing chunking characteristics. + let result = ":"; + result += data.category + FIELD_DELIMITER + data.name + FIELD_DELIMITER + data.term + LINE_DELIMITER; + result += (data.version ?? "") + LINE_DELIMITER; + result += data.mtime + LINE_DELIMITER; + for (const file of data.files) { + result += + file.filename + + FIELD_DELIMITER + + (file.displayName ?? "") + + FIELD_DELIMITER + + (file.version ?? "") + + LINE_DELIMITER; + const hash = dependencies.digestHash(file.data ?? []); + result += file.mtime + FIELD_DELIMITER + file.size + FIELD_DELIMITER + hash + LINE_DELIMITER; + for (const piece of file.data ?? []) { + result += piece + FIELD_DELIMITER; + } + result += LINE_DELIMITER; + } + return result; + } + + function deserialize(source: string[], defaultValue: T): T { + try { + if (source[0][0] == ":") { + return deserializeCustomFormat(source) as T; + } + return JSON.parse(source.join("")) as T; + } catch { + try { + return dependencies.parseYaml(source.join("")) as T; + } catch { + return defaultValue; + } + } + } + + const dummyHead = serialize({ + category: "CONFIG", + name: "migrated", + files: [], + mtime: 0, + term: "-", + displayName: "MIRAGED", + }); + const dummyEnd = FIELD_DELIMITER + LINE_DELIMITER + "\u200c"; + + return { serialize, deserialize, dummyHead, dummyEnd }; +} diff --git a/src/features/ConfigSync/customisationSyncCodec.unit.spec.ts b/src/features/ConfigSync/customisationSyncCodec.unit.spec.ts new file mode 100644 index 00000000..d9bfadbc --- /dev/null +++ b/src/features/ConfigSync/customisationSyncCodec.unit.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts"; + +function createCodec() { + const digestHash = vi.fn((data: string[]) => `digest:${data.join("|")}`); + const parseYaml = vi.fn((_source: string): unknown => { + throw new Error("Invalid YAML"); + }); + return { + codec: createCustomisationSyncCodec({ digestHash, parseYaml }), + digestHash, + parseYaml, + }; +} + +const data: PluginDataEx = { + category: "PLUGIN_DATA", + name: "example", + term: "device-a", + version: "1.2.3", + mtime: 123, + files: [ + { + filename: ".obsidian/plugins/example/data.json", + displayName: "data.json", + version: "2.0.0", + mtime: 120, + size: 6, + data: ["YWJj", "ZGVm"], + }, + ], +}; + +describe("compatibility: Customisation Sync codec", () => { + it("preserves the existing custom wire format", () => { + const { codec, digestHash } = createCodec(); + + expect(codec.serialize(data)).toBe( + ":PLUGIN_DATA\u200bexample\u200bdevice-a\n" + + "1.2.3\n" + + "123\n" + + ".obsidian/plugins/example/data.json\u200bdata.json\u200b2.0.0\n" + + "120\u200b6\u200bdigest:YWJj|ZGVm\n" + + "YWJj\u200bZGVm\u200b\n" + ); + expect(digestHash).toHaveBeenCalledWith(["YWJj", "ZGVm"]); + }); + + it("round-trips the custom format across arbitrary source chunks", () => { + const { codec } = createCodec(); + const serialised = codec.serialize(data); + const source = [serialised.slice(0, 13), serialised.slice(13, 47), serialised.slice(47), ""]; + + expect(codec.deserialize(source, {} as PluginDataEx)).toEqual({ + ...data, + files: [ + { + ...data.files[0], + hash: "digest:YWJj|ZGVm", + }, + ], + }); + }); + + it("retains JSON as the first legacy fallback", () => { + const { codec, parseYaml } = createCodec(); + + expect(codec.deserialize(['{"value":1}'], { value: 0 })).toEqual({ value: 1 }); + expect(parseYaml).not.toHaveBeenCalled(); + }); + + it("uses the injected YAML parser after JSON parsing fails", () => { + const parseYaml = vi.fn(() => ({ value: 2 })); + const codec = createCustomisationSyncCodec({ digestHash: vi.fn(() => "hash"), parseYaml }); + + expect(codec.deserialize(["value: 2"], { value: 0 })).toEqual({ value: 2 }); + expect(parseYaml).toHaveBeenCalledWith("value: 2"); + }); + + it("returns the supplied default when every decoder rejects the input", () => { + const { codec } = createCodec(); + const defaultValue = { retained: true }; + + expect(codec.deserialize([], defaultValue)).toBe(defaultValue); + }); + + it("preserves the V2 migration sentinels", () => { + const { codec } = createCodec(); + + expect(codec.dummyHead).toBe(":CONFIG\u200bmigrated\u200b-\n\n0\n"); + expect(codec.dummyEnd).toBe("\u200b\n\u200c"); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncContext.command.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.command.unit.spec.ts new file mode 100644 index 00000000..0a93cae8 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncContext.command.unit.spec.ts @@ -0,0 +1,88 @@ +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 { cancelTask } from "@/common/utils.ts"; +import { CustomisationSyncContext } from "./customisationSyncContext"; +import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts"; + +describe("CustomisationSyncContext commands", () => { + it("keeps the legacy dialogue methods as delegates to the host-owned UI", () => { + const control = { + open: vi.fn(), + close: vi.fn(), + isOpen: vi.fn(), + }; + const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext; + Object.assign(configSync, { + dependencies: createCustomisationSyncTestDependencies({ + getUIControl: () => control, + }), + }); + + configSync.showPluginSyncModal(); + configSync.hidePluginSyncModal(); + + expect(control.open).toHaveBeenCalledOnce(); + expect(control.close).toHaveBeenCalledOnce(); + }); + + it("releases every owned processor and reactive subscription", () => { + const hideConfigurationNotice = vi.fn(); + const publishScanCount = vi.fn(); + const periodicPluginSweepProcessor = { disable: vi.fn() }; + const pluginScanProcessor = { terminate: vi.fn() }; + const pluginScanProcessorV2 = { terminate: vi.fn() }; + const pluginScanningChanged = vi.fn(); + const offChanged = vi.fn(); + const setEnumerationActive = vi.fn(); + const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext; + Object.assign(configSync, { + dependencies: createCustomisationSyncTestDependencies({ + hideConfigurationNotice, + publishScanCount, + }), + periodicPluginSweepProcessor, + pluginScanProcessor, + pluginScanProcessorV2, + pluginScanningChanged, + scanProgress: { offChanged }, + enumerationActive: { set: setEnumerationActive }, + }); + + configSync.dispose(); + + expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration"); + expect(hideConfigurationNotice).toHaveBeenCalledOnce(); + expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce(); + expect(pluginScanProcessor.terminate).toHaveBeenCalledOnce(); + expect(pluginScanProcessorV2.terminate).toHaveBeenCalledOnce(); + expect(offChanged).toHaveBeenCalledWith(pluginScanningChanged); + expect(setEnumerationActive).toHaveBeenCalledWith(false); + expect(publishScanCount).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncContext.path.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.path.unit.spec.ts new file mode 100644 index 00000000..112a7107 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncContext.path.unit.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +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().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.getFileCategory(path)).toBe(""); + expect(withoutPluginEtc.getFileCategory(path)).toBe(""); + }); + + it("recognises only classified files below the Obsidian configuration directory", () => { + const configSync = createConfigSync(); + + expect(configSync.isTargetPath(".obsidian/app.json")).toBe(true); + expect(configSync.isTargetPath(".obsidian/plugins/example/main.js")).toBe(true); + expect(configSync.isTargetPath(".obsidian/workspace")).toBe(false); + expect(configSync.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().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().filenameWithUnifiedKey(path)).toBe(expected); + }); + + it("uses an explicit device name when supplied", () => { + const configSync = createConfigSync(); + + expect(configSync.filenameToUnifiedKey(".obsidian/app.json", "device-b")).toBe( + "ix:device-b/CONFIG/app.json.md" + ); + expect(configSync.filenameWithUnifiedKey(".obsidian/app.json", "device-b")).toBe( + "ix:device-b/CONFIG/app.json%app.json" + ); + expect(configSync.unifiedKeyPrefixOfTerminal("device-b")).toBe("ix:device-b/"); + }); + + it("parses a V2 document path and derives its V1 compatibility path", () => { + expect( + createConfigSync().parseUnifiedPath("ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix) + ).toEqual({ + device: "device-a", + category: "PLUGIN_MAIN", + key: "example", + filename: "main.js", + pathV1: "ix:device-a/PLUGIN_MAIN/example.md", + }); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncContext.routing.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.routing.unit.spec.ts new file mode 100644 index 00000000..66a21771 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncContext.routing.unit.spec.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +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 { scheduleTask } from "@/common/utils.ts"; +import { CustomisationSyncContext } from "./customisationSyncContext.ts"; +import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts"; + +const PATH = ".obsidian/plugins/example/data.json" as FilePath; + +function createConfigSync(options: { ready?: boolean; suspended?: boolean; enabled?: boolean; owned?: boolean } = {}) { + const settings = { + usePluginSync: true, + usePluginSyncV2: true, + usePluginEtc: true, + pluginSyncExtendedSetting: {}, + }; + const ownsLocalFile = vi.fn(() => options.owned ?? true); + const statHidden = vi.fn(async () => ({ type: "file", mtime: 1 })); + const recentProcessedInternalFiles = Object.assign([] as string[], { + contains: vi.fn(() => false), + }); + const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext; + Object.assign(configSync, { + dependencies: createCustomisationSyncTestDependencies({ + getConfigDir: () => ".obsidian", + getSettings: () => settings as never, + storageAccess: { statHidden } as never, + ownsLocalFile, + }), + _isMainReady: vi.fn(() => options.ready ?? true), + _isMainSuspended: vi.fn(() => options.suspended ?? false), + isThisModuleEnabled: vi.fn(() => options.enabled ?? true), + recentProcessedInternalFiles, + _log: vi.fn(), + }); + return { configSync, ownsLocalFile, statHidden }; +} + +describe("Customisation Sync raw-event admission", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("schedules a recognised path granted by the composition owner", async () => { + const { configSync, ownsLocalFile } = createConfigSync(); + + await expect(configSync._anyProcessOptionalFileEvent(PATH)).resolves.toBe(true); + expect(ownsLocalFile).toHaveBeenCalledWith(PATH); + expect(scheduleTask).toHaveBeenCalledOnce(); + }); + + it("rejects an event while the host is not ready", async () => { + const { configSync, statHidden } = createConfigSync({ ready: false }); + + await expect(configSync._anyProcessOptionalFileEvent(PATH)).resolves.toBe(false); + expect(statHidden).not.toHaveBeenCalled(); + expect(scheduleTask).not.toHaveBeenCalled(); + }); + + it("rejects a path outside the recognised Customisation Sync categories", async () => { + const { configSync, ownsLocalFile } = createConfigSync(); + + await expect(configSync._anyProcessOptionalFileEvent(".obsidian/workspace" as FilePath)).resolves.toBe(false); + expect(ownsLocalFile).not.toHaveBeenCalled(); + expect(scheduleTask).not.toHaveBeenCalled(); + }); + + it("rejects a recognised path assigned to another owner", async () => { + const { configSync, statHidden } = createConfigSync({ owned: false }); + + await expect(configSync._anyProcessOptionalFileEvent(PATH)).resolves.toBe(false); + expect(statHidden).not.toHaveBeenCalled(); + expect(scheduleTask).not.toHaveBeenCalled(); + }); + + it.each([ + ["suspended", { suspended: true }], + ["disabled", { enabled: false }], + ] as const)("rejects an event while %s", async (_label, options) => { + const { configSync } = createConfigSync(options); + + await expect(configSync._anyProcessOptionalFileEvent(PATH)).resolves.toBe(false); + expect(scheduleTask).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncContext.scan-routing.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.scan-routing.unit.spec.ts new file mode 100644 index 00000000..98d4a30e --- /dev/null +++ b/src/features/ConfigSync/customisationSyncContext.scan-routing.unit.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FilePath, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +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"; + +const PATH = ".obsidian/plugins/example/data.json" as FilePath; +const V1_PATH = "ix:device-a/PLUGIN_DATA/example.md" as FilePathWithPrefix; +const V2_PATH = "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix; + +function asyncEntries(entries: object[]) { + return { + async *[Symbol.asyncIterator]() { + yield* entries; + }, + }; +} + +function createConfigSync(options: { + useV2: boolean; + localFiles?: FilePath[]; + databasePaths?: FilePathWithPrefix[]; + ownsLocalFile?: boolean; + ownsLocalDocument?: boolean; +}) { + const storeCustomizationFiles = vi.fn(async () => true); + const storeCustomisationFileV2 = vi.fn(async () => true); + const deleteConfigOnDatabase = vi.fn(async () => true); + const ownsLocalFile = vi.fn(() => options.ownsLocalFile ?? true); + const ownsLocalDocument = vi.fn(() => options.ownsLocalDocument ?? true); + const databaseEntries = (options.databasePaths ?? []).map((path) => ({ _id: path, path })); + const localDatabase = { + findEntries: vi.fn(() => asyncEntries(databaseEntries)), + allDocsRaw: vi.fn(async () => ({ + rows: databaseEntries.map((doc) => ({ doc: { ...doc, deleted: false } })), + })), + }; + const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext; + Object.assign(configSync, { + dependencies: createCustomisationSyncTestDependencies({ + getConfigDir: () => ".obsidian", + getSettings: () => ({ + usePluginSync: true, + usePluginSyncV2: options.useV2, + usePluginEtc: true, + pluginSyncExtendedSetting: {}, + autoSweepPlugins: false, + autoSweepPluginsPeriodic: false, + watchInternalFileChanges: false, + notifyPluginOrSettingUpdated: false, + }), + getLocalDatabase: () => localDatabase as never, + ownsLocalFile, + ownsLocalDocument, + }), + scanInternalFiles: vi.fn(async () => options.localFiles ?? []), + 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, + deleteConfigOnDatabase, + updatePluginList: vi.fn(async () => undefined), + _log: vi.fn(), + }); + return { + configSync, + deleteConfigOnDatabase, + ownsLocalDocument, + ownsLocalFile, + storeCustomisationFileV2, + storeCustomizationFiles, + }; +} + +describe("Customisation Sync scan ownership", () => { + it.each([ + ["V1", false], + ["V2", true], + ] as const)("does not store a %s local file assigned to another owner", async (_label, useV2) => { + const fixture = createConfigSync({ useV2, localFiles: [PATH], ownsLocalFile: false }); + + await fixture.configSync.scanAllConfigFiles(false); + + expect(fixture.ownsLocalFile).toHaveBeenCalledWith(PATH); + expect(fixture.storeCustomizationFiles).not.toHaveBeenCalled(); + expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled(); + }); + + it("does not create a deletion for an existing V1 document which is no longer locally owned", async () => { + const fixture = createConfigSync({ + useV2: false, + databasePaths: [V1_PATH], + ownsLocalDocument: false, + }); + + await fixture.configSync.scanAllConfigFiles(false); + + expect(fixture.ownsLocalDocument).toHaveBeenCalledWith(V1_PATH); + expect(fixture.deleteConfigOnDatabase).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncContext.state.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.state.unit.spec.ts new file mode 100644 index 00000000..ce2958af --- /dev/null +++ b/src/features/ConfigSync/customisationSyncContext.state.unit.spec.ts @@ -0,0 +1,43 @@ +import { get } from "svelte/store"; +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/PeriodicProcessor.ts", () => ({ + PeriodicProcessor: class PeriodicProcessor { + disable = vi.fn(); + enable = vi.fn(); + }, +})); +vi.mock("octagonal-wheels/concurrency/processor", () => ({ + QueueProcessor: class QueueProcessor { + clearQueue = vi.fn(); + enqueue = vi.fn(); + terminate = vi.fn(); + startPipeline() { + return this; + } + }, +})); +vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ + getObsidianCommunityPluginManager: vi.fn(), +})); +import { CustomisationSyncContext } from "./customisationSyncContext.ts"; +import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts"; + +describe("CustomisationSyncContext state ownership", () => { + it("does not share catalogue or presentation state between context instances", () => { + const first = new CustomisationSyncContext(createCustomisationSyncTestDependencies()); + const second = new CustomisationSyncContext(createCustomisationSyncTestDependencies()); + + expect(first.catalogue).not.toBe(second.catalogue); + expect(first.enumerationActive).not.toBe(second.enumerationActive); + expect(first.migrationProgress).not.toBe(second.migrationProgress); + expect(first.manifests).not.toBe(second.manifests); + expect(get(first.manifests)).not.toBe(get(second.manifests)); + }); +}); diff --git a/src/features/ConfigSync/CmdConfigSync.ts b/src/features/ConfigSync/customisationSyncContext.ts similarity index 68% rename from src/features/ConfigSync/CmdConfigSync.ts rename to src/features/ConfigSync/customisationSyncContext.ts index 4201385f..1524d11a 100644 --- a/src/features/ConfigSync/CmdConfigSync.ts +++ b/src/features/ConfigSync/customisationSyncContext.ts @@ -1,14 +1,6 @@ import { writable } from "svelte/store"; import type PouchDB from "pouchdb-core"; -import { - type PluginManifest, - parseYaml, - normalizePath, - type ListedFiles, - diff_match_patch, - Platform, - addIcon, -} from "@/deps.ts"; +import { type PluginManifest, parseYaml, normalizePath, type ListedFiles, diff_match_patch } from "@/deps.ts"; import type { EntryDoc, @@ -19,16 +11,16 @@ import type { AnyEntry, SavingEntry, diff_result, + SYNC_MODE, + ObsidianLiveSyncSettings, + LOG_LEVEL, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { - CANCELLED, - LEAVE_TO_SUBSEQUENT, LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MODE_SELECTIVE, - MODE_SHINY, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ICXHeader, PERIODIC_PLUGIN_SWEEP } from "@/common/types.ts"; import { @@ -50,237 +42,51 @@ import { readString, } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert"; import { serialized, shareRunningResult } from "octagonal-wheels/concurrency/lock"; -import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts"; import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; import { cancelTask, EVEN, isCustomisationSyncMetadata, isPluginMetadata, scheduleTask } from "@/common/utils.ts"; -import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts"; -import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts"; import { QueueProcessor } from "octagonal-wheels/concurrency/processor"; -import { pluginScanningCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores"; +import { reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive"; import { base64ToArrayBuffer, base64ToString } from "octagonal-wheels/binary/base64"; -import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; -import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts"; -import { PluginDialogModal } from "./PluginDialogModal.ts"; import { $msg } from "@/common/translation"; -import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; -import type { LiveSyncCore } from "@/main.ts"; import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"; import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts"; -import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts"; +import { + createCustomisationSyncDevicePrefix, + createCustomisationSyncV1DocumentPath, + createCustomisationSyncV2DocumentPath, + getCustomisationSyncFileCategory, + isCustomisationSyncTargetPath, + parseCustomisationSyncV2DocumentPath, +} from "./customisationSyncPaths.ts"; +import { createCustomisationSyncCodec, type PluginDataEx, type PluginDataExFile } from "./customisationSyncCodec.ts"; +import type { + CustomisationSyncDialogView, + CustomisationSyncUIControl, + IPluginDataExDisplay, + LoadedEntryPluginDataExFile, + PluginDataExDisplay, +} from "./customisationSyncView.ts"; +import { + REPLICATION_PROGRESS_PRESENTATIONS, + USER_INITIATED_REPLICATION_AUTHORITY, +} from "@vrtmrz/livesync-commonlib/replication"; +import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { IPathService, IReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; +import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; + +export type { PluginDataEx, PluginDataExFile } from "./customisationSyncCodec.ts"; +export type { IPluginDataExDisplay, PluginDataExDisplay } from "./customisationSyncView.ts"; -const d = "\u200b"; -const d2 = "\n"; const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration"; -function serialize(data: PluginDataEx): string { - // For higher performance, create custom plug-in data strings. - // Self-hosted LiveSync uses `\n` to split chunks. Therefore, grouping together those with similar entropy would work nicely. - let ret = ""; - ret += ":"; - ret += data.category + d + data.name + d + data.term + d2; - ret += (data.version ?? "") + d2; - ret += data.mtime + d2; - for (const file of data.files) { - ret += file.filename + d + (file.displayName ?? "") + d + (file.version ?? "") + d2; - const hash = digestHash(file.data ?? []); - ret += file.mtime + d + file.size + d + hash + d2; - for (const data of file.data ?? []) { - ret += data + d; - } - ret += d2; - } - return ret; -} -const DUMMY_HEAD = serialize({ - category: "CONFIG", - name: "migrated", - files: [], - mtime: 0, - term: "-", - displayName: `MIRAGED`, -}); -const DUMMY_END = d + d2 + "\u200c"; -function splitWithDelimiters(sources: string[]): string[] { - const result: string[] = []; - for (const str of sources) { - let startIndex = 0; - const maxLen = str.length; - let i = -1; - let i1; - let i2; - do { - i1 = str.indexOf(d, startIndex); - i2 = str.indexOf(d2, startIndex); - if (i1 == -1 && i2 == -1) { - break; - } - if (i1 == -1) { - i = i2; - } else if (i2 == -1) { - i = i1; - } else { - i = i1 < i2 ? i1 : i2; - } - result.push(str.slice(startIndex, i + 1)); - startIndex = i + 1; - } while (i < maxLen); - if (startIndex < maxLen) { - result.push(str.slice(startIndex)); - } - } - - // To keep compatibilities - if (sources[sources.length - 1] == "") { - result.push(""); - } - - return result; -} - -function getTokenizer(source: string[]) { - const sources = splitWithDelimiters(source); - sources[0] = sources[0].substring(1); - let pos = 0; - let lineRunOut = false; - const t = { - next(): string { - if (lineRunOut) { - return ""; - } - if (pos >= sources.length) { - return ""; - } - const item = sources[pos]; - if (!item.endsWith(d2)) { - pos++; - } else { - lineRunOut = true; - } - if (item.endsWith(d) || item.endsWith(d2)) { - return item.substring(0, item.length - 1); - } else { - return item + this.next(); - } - }, - nextLine() { - if (lineRunOut) { - pos++; - } else { - while (!sources[pos].endsWith(d2)) { - pos++; - if (pos >= sources.length) break; - } - pos++; - } - lineRunOut = false; - }, - }; - return t; -} - -function deserialize2(str: string[]): PluginDataEx { - const tokens = getTokenizer(str); - const ret = {} as PluginDataEx; - const category = tokens.next(); - const name = tokens.next(); - const term = tokens.next(); - tokens.nextLine(); - const version = tokens.next(); - tokens.nextLine(); - const mtime = Number(tokens.next()); - tokens.nextLine(); - const result: PluginDataEx = Object.assign(ret, { - category, - name, - term, - version, - mtime, - files: [] as PluginDataExFile[], - }); - let filename = ""; - do { - filename = tokens.next(); - if (!filename) break; - const displayName = tokens.next(); - const version = tokens.next(); - tokens.nextLine(); - const mtime = Number(tokens.next()); - const size = Number(tokens.next()); - const hash = tokens.next(); - tokens.nextLine(); - const data = [] as string[]; - let piece = ""; - do { - piece = tokens.next(); - if (piece == "") break; - data.push(piece); - } while (piece != ""); - result.files.push({ - filename, - displayName, - version, - mtime, - size, - data, - hash, - }); - tokens.nextLine(); - } while (filename); - return result; -} - -function deserialize(str: string[], def: T) { - try { - if (str[0][0] == ":") { - const o = deserialize2(str); - return o; - } - return JSON.parse(str.join("")) as T; - } catch { - try { - const parsed: unknown = parseYaml(str.join("")); - return parsed as T; - } catch { - return def; - } - } -} - -export const pluginList = writable([] as PluginDataExDisplay[]); -export const pluginIsEnumerating = writable(false); -export const pluginV2Progress = writable(0); - -export type PluginDataExFile = { - filename: string; - data: string[]; - mtime: number; - size: number; - version?: string; - hash?: string; - displayName?: string; -}; -export interface IPluginDataExDisplay { - documentPath: FilePathWithPrefix; - category: string; - name: string; - term: string; - displayName?: string; - files: (LoadedEntryPluginDataExFile | PluginDataExFile)[]; - version?: string; - mtime: number; -} -export type PluginDataExDisplay = { - documentPath: FilePathWithPrefix; - category: string; - name: string; - term: string; - displayName?: string; - files: PluginDataExFile[]; - version?: string; - mtime: number; -}; -type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile; +const { + serialize, + deserialize, + dummyHead: DUMMY_HEAD, + dummyEnd: DUMMY_END, +} = createCustomisationSyncCodec({ digestHash, parseYaml }); function categoryToFolder(category: string, configDir: string = ""): string { switch (category) { @@ -301,18 +107,6 @@ function categoryToFolder(category: string, configDir: string = ""): string { } } -export const pluginManifests = new Map(); -export const pluginManifestStore = writable(pluginManifests); - -function setManifest(key: string, manifest: PluginManifest) { - const old = pluginManifests.get(key); - if (old && !isObjectDifferent(manifest, old)) { - return; - } - pluginManifests.set(key, manifest); - pluginManifestStore.set(pluginManifests); -} - export class PluginDataExDisplayV2 { documentPath: FilePathWithPrefix; category: string; @@ -323,7 +117,10 @@ export class PluginDataExDisplayV2 { name: string; confKey: string; - constructor(data: IPluginDataExDisplay) { + constructor( + data: IPluginDataExDisplay, + private readonly manifestLookup: ReadonlyMap + ) { this.documentPath = `${data.documentPath}` as FilePathWithPrefix; this.category = `${data.category}`; this.name = `${data.name}`; @@ -351,7 +148,7 @@ export class PluginDataExDisplayV2 { _version: string | undefined; applyLoadedManifest() { - const manifest = pluginManifests.get(this.confKey); + const manifest = this.manifestLookup.get(this.confKey); if (manifest) { this._displayName = manifest.name; if (this.category == "PLUGIN_MAIN" || this.category == "THEME") { @@ -371,126 +168,253 @@ export class PluginDataExDisplayV2 { return ~~this.files.reduce((a, b) => a + b.mtime, 0) / this.files.length; } } -export type PluginDataEx = { - documentPath?: FilePathWithPrefix; - category: string; - name: string; - displayName?: string; - term: string; - files: PluginDataExFile[]; - version?: string; - mtime: number; +type CustomisationSyncSettings = Pick< + ObsidianLiveSyncSettings, + | "usePluginSync" + | "usePluginSyncV2" + | "usePluginEtc" + | "pluginSyncExtendedSetting" + | "autoSweepPlugins" + | "autoSweepPluginsPeriodic" + | "watchInternalFileChanges" + | "notifyPluginOrSettingUpdated" +>; + +type CustomisationSyncDatabase = Pick< + LiveSyncLocalDB, + "allDocsRaw" | "findEntries" | "getDBEntry" | "getDBEntryFromMeta" | "getDBEntryMeta" | "putDBEntry" | "putRaw" +>; + +type CustomisationSyncStorage = Pick< + StorageAccess, + "ensureDir" | "readHiddenFileBinary" | "readHiddenFileText" | "statHidden" | "writeHiddenFileAuto" +>; + +export type CustomisationSyncPeriodicProcessor = { + enable(interval: number): void; + disable(): void; }; -export class ConfigSync extends LiveSyncCommands { - constructor(core: LiveSyncCore) { - super(core); - pluginScanningCount.onChanged((e) => { - const total = e.value; - pluginIsEnumerating.set(total != 0); - }); +export type CustomisationSyncContextDependencies = { + getSettings(): CustomisationSyncSettings; + getLocalDatabase(): CustomisationSyncDatabase; + storageAccess: CustomisationSyncStorage; + path: Pick; + log: LogFunction; + getConfigDir(): string; + getDeviceAndVaultName(): string; + setDeviceAndVaultName(name: string): void; + saveSettingData(): Promise; + applySettings(partial: Partial, saveImmediately?: boolean): Promise; + replicateUserInitiated: IReplicationService["replicateUserInitiated"]; + askString(title: string, key: string, placeholder: string): Promise; + isReady(): boolean; + isSuspended(): boolean; + askRestart(): void; + createPeriodicProcessor(process: () => Promise): CustomisationSyncPeriodicProcessor; + listFiles(path: string): Promise; + resolveJsonConflict( + path: FilePath, + files: [LoadedEntryPluginDataExFile, LoadedEntryPluginDataExFile], + remoteName: string, + apply: (content: string) => Promise + ): Promise; + selectTextFile(path: FilePath, diffResult: diff_result, remoteName: string): Promise<"A" | "B" | false>; + reloadPlugin(configDir: string, pluginName: string): Promise; + getFallbackDeviceName(): string; + showConfigurationNotice(openDialog: () => void): void; + hideConfigurationNotice(): void; + getUIControl(): CustomisationSyncUIControl | undefined; + ownsLocalFile(path: FilePath): boolean; + ownsLocalDocument(path: FilePathWithPrefix): boolean; + publishScanCount(count: number): void; +}; + +export class CustomisationSyncContext implements CustomisationSyncDialogView { + private readonly dependencies: CustomisationSyncContextDependencies; + private readonly scanProgress = reactiveSource(0); + private readonly pluginScanningChanged: Parameters["onChanged"]>[0] = (event) => { + this.enumerationActive.set(event.value != 0); + this.dependencies.publishScanCount(event.value); + }; + + readonly catalogue = writable([]); + readonly enumerationActive = writable(false); + readonly migrationProgress = writable(0); + private readonly pluginManifests = new Map(); + readonly manifests = writable(this.pluginManifests); + + readonly periodicPluginSweepProcessor: CustomisationSyncPeriodicProcessor; + + constructor(dependencies: CustomisationSyncContextDependencies) { + this.dependencies = dependencies; + this.periodicPluginSweepProcessor = dependencies.createPeriodicProcessor( + async () => await this.scanAllConfigFiles(false) + ); + this.scanProgress.onChanged(this.pluginScanningChanged); } get configDir() { - return this.core.services.API.getSystemConfigDir(); + return this.dependencies.getConfigDir(); } - get kvDB() { - return this.core.kvDB; + + private get settings() { + return this.dependencies.getSettings(); + } + + private get localDatabase() { + return this.dependencies.getLocalDatabase(); + } + + private get storageAccess() { + return this.dependencies.storageAccess; + } + + private async path2id(filename: FilePathWithPrefix | FilePath, prefix?: string) { + return await this.dependencies.path.path2id(filename, prefix); + } + + private getPath(entry: AnyEntry): FilePathWithPrefix { + return this.dependencies.path.getPath(entry); + } + + _isMainReady() { + return this.dependencies.isReady(); + } + + _isMainSuspended() { + return this.dependencies.isSuspended(); + } + + private _log(message: unknown, level?: LOG_LEVEL, key?: string) { + this.dependencies.log(message, level, key); } get useV2() { - return this.core.settings.usePluginSyncV2; + return this.settings.usePluginSyncV2; } get useSyncPluginEtc() { - return this.core.settings.usePluginEtc; + return this.settings.usePluginEtc; } isThisModuleEnabled() { - return this.core.settings.usePluginSync; + return this.settings.usePluginSync; } - pluginDialog?: PluginDialogModal = undefined; - periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false)); + isEnabled(): boolean { + return this.isThisModuleEnabled(); + } + + getDeviceAndVaultName(): string { + return this.dependencies.getDeviceAndVaultName(); + } + + getConfiguredModes() { + return Object.values(this.settings.pluginSyncExtendedSetting).map((entry) => ({ + ...entry, + files: [...entry.files], + })); + } + + isPluginEtcEnabled(): boolean { + return this.useSyncPluginEtc; + } + + updateConfiguredMode(key: string, mode: SYNC_MODE, files: string[]): void { + if (mode == MODE_SELECTIVE) { + delete this.settings.pluginSyncExtendedSetting[key]; + } else { + this.settings.pluginSyncExtendedSetting[key] = { + key, + mode, + files: [...files], + }; + } + void this.dependencies.saveSettingData(); + } + + getConfiguredTargetFiles(key: string): string[] { + const configDir = normalizePath(this.configDir); + return (this.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((path) => `${configDir}/${path}`); + } + + async synchronise(): Promise { + await this.dependencies.replicateUserInitiated({ + trigger: "manual", + progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE, + interaction: USER_INITIATED_REPLICATION_AUTHORITY, + }); + } + + askString(title: string, key: string, placeholder: string): Promise { + return this.dependencies.askString(title, key, placeholder); + } + + async compareFileUsingDisplayData( + dataA: IPluginDataExDisplay, + dataB: IPluginDataExDisplay, + filename: string + ): Promise { + const dataACopy = + dataA instanceof PluginDataExDisplayV2 + ? new PluginDataExDisplayV2(dataA, this.pluginManifests) + : { ...dataA }; + const dataBCopy = + dataB instanceof PluginDataExDisplayV2 + ? new PluginDataExDisplayV2(dataB, this.pluginManifests) + : { ...dataB }; + dataACopy.files = dataACopy.files.filter((file) => file.filename == filename); + dataBCopy.files = dataBCopy.files.filter((file) => file.filename == filename); + return await this.compareUsingDisplayData(dataACopy, dataBCopy, true); + } + + 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)); + } pluginList: IPluginDataExDisplay[] = []; showPluginSyncModal() { - if (!this.isThisModuleEnabled()) { - return; - } - if (this.pluginDialog) { - this.pluginDialog.open(); - } else { - this.pluginDialog = new PluginDialogModal(this.app, this.services.context.liveSyncPlugin); - this.pluginDialog.open(); - } + this.dependencies.getUIControl()?.open(); } hidePluginSyncModal() { - if (this.pluginDialog != null) { - this.pluginDialog.close(); - this.pluginDialog = undefined; - } + this.dependencies.getUIControl()?.close(); } - onunload() { + dispose() { cancelTask(UPDATED_CONFIGURATION_NOTICE_KEY); - this.hidePluginSyncModal(); this.periodicPluginSweepProcessor?.disable(); - this.services.context.notices.hide(UPDATED_CONFIGURATION_NOTICE_KEY); + this.pluginScanProcessor?.terminate(); + this.pluginScanProcessorV2?.terminate(); + this.scanProgress.offChanged(this.pluginScanningChanged); + this.enumerationActive.set(false); + this.dependencies.publishScanCount(0); + this.dependencies.hideConfigurationNotice(); } - addRibbonIcon = this.services.API.addRibbonIcon.bind(this.services.API); - onload() { - addIcon( - "custom-sync", - ` - - ` - ); - this.services.API.addCommand({ - id: "livesync-plugin-dialog-ex", - name: "Show customization sync dialog", - checkCallback: (checking) => { - if (!this.isThisModuleEnabled()) { - return false; - } - if (!checking) { - this.showPluginSyncModal(); - } - return true; - }, - }); - this.addRibbonIcon("custom-sync", $msg("cmdConfigSync.showCustomizationSync"), () => { - this.showPluginSyncModal(); - }).addClass("livesync-ribbon-showcustom"); - eventHub.onEvent(EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, () => this.showPluginSyncModal()); + + private setManifest(key: string, manifest: PluginManifest) { + const old = this.pluginManifests.get(key); + if (old && !isObjectDifferent(manifest, old)) return; + this.pluginManifests.set(key, manifest); + this.manifests.set(this.pluginManifests); } getFileCategory( filePath: string ): "CONFIG" | "THEME" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_ETC" | "PLUGIN_DATA" | "" { - if (filePath.split("/").length == 2 && filePath.endsWith(".json")) return "CONFIG"; - if (filePath.split("/").length == 4 && filePath.startsWith(`${this.configDir}/themes/`)) return "THEME"; - if (filePath.startsWith(`${this.configDir}/snippets/`) && filePath.endsWith(".css")) return "SNIPPET"; - if (filePath.startsWith(`${this.configDir}/plugins/`)) { - if ( - filePath.endsWith("/styles.css") || - filePath.endsWith("/manifest.json") || - filePath.endsWith("/main.js") - ) { - return "PLUGIN_MAIN"; - } else if (filePath.endsWith("/data.json")) { - return "PLUGIN_DATA"; - } else { - // Planned at v0.19.0, realised v0.23.18! - return this.useV2 && this.useSyncPluginEtc ? "PLUGIN_ETC" : ""; - } - // return "PLUGIN"; - } - return ""; + return getCustomisationSyncFileCategory(filePath, { + configDir: this.configDir, + useV2: this.useV2, + usePluginEtc: this.useSyncPluginEtc, + }); } isTargetPath(filePath: string): boolean { - if (!filePath.startsWith(this.configDir)) return false; - // Idea non-filter option? - return this.getFileCategory(filePath) != ""; + return isCustomisationSyncTargetPath(filePath, { + configDir: this.configDir, + useV2: this.useV2, + usePluginEtc: this.useSyncPluginEtc, + }); } - private async _everyOnDatabaseInitialized(showNotice: boolean) { + async _everyOnDatabaseInitialized(showNotice: boolean) { if (!this.isThisModuleEnabled()) return true; try { this._log("Scanning customizations..."); @@ -525,15 +449,10 @@ export class ConfigSync extends LiveSyncCommands { ); return true; } - _everyAfterResumeProcess(): Promise { - const q = activeDocument.querySelector(`.livesync-ribbon-showcustom`); - q?.toggleClass("sls-hidden", !this.isThisModuleEnabled()); - return Promise.resolve(true); - } async reloadPluginList(showMessage: boolean) { this.pluginList = []; this.loadedManifest_mTime.clear(); - pluginList.set(this.pluginList); + this.catalogue.set(this.pluginList); await this.updatePluginList(showMessage); } async loadPluginData(path: FilePathWithPrefix): Promise { @@ -585,7 +504,7 @@ export class ConfigSync extends LiveSyncCommands { newList = newList.filter((x) => x.documentPath != pluginData.documentPath); newList.push(pluginData); this.pluginList = newList; - pluginList.set(newList); + this.catalogue.set(newList); } // Failed to load return []; @@ -602,7 +521,7 @@ export class ConfigSync extends LiveSyncCommands { delay: 100, yieldThreshold: 10, maintainDelay: false, - totalRemainingReactiveSource: pluginScanningCount, + totalRemainingReactiveSource: this.scanProgress, } ).startPipeline(); @@ -619,7 +538,7 @@ export class ConfigSync extends LiveSyncCommands { newList = newList.filter((x) => x.documentPath != pluginData.documentPath); newList.push(pluginData); this.pluginList = newList; - pluginList.set(newList); + this.catalogue.set(newList); } // Failed to load return []; @@ -636,34 +555,31 @@ export class ConfigSync extends LiveSyncCommands { delay: 100, yieldThreshold: 10, maintainDelay: false, - totalRemainingReactiveSource: pluginScanningCount, + totalRemainingReactiveSource: this.scanProgress, } ).startPipeline(); filenameToUnifiedKey(path: string, termOverRide?: string) { - const term = termOverRide || this.services.setting.getDeviceAndVaultName(); - const category = this.getFileCategory(path); - const name = - category == "CONFIG" || category == "SNIPPET" - ? path.split("/").slice(-1)[0] - : category == "PLUGIN_ETC" - ? path.split("/").slice(-2).join("/") - : path.split("/").slice(-2)[0]; - return `${ICXHeader}${term}/${category}/${name}.md` as FilePathWithPrefix; + const term = termOverRide || this.dependencies.getDeviceAndVaultName(); + return createCustomisationSyncV1DocumentPath(path, term, { + configDir: this.configDir, + useV2: this.useV2, + usePluginEtc: this.useSyncPluginEtc, + }); } filenameWithUnifiedKey(path: string, termOverRide?: string) { - const term = termOverRide || this.services.setting.getDeviceAndVaultName(); - const category = this.getFileCategory(path); - const name = - category == "CONFIG" || category == "SNIPPET" ? path.split("/").slice(-1)[0] : path.split("/").slice(-2)[0]; - const baseName = category == "CONFIG" || category == "SNIPPET" ? name : path.split("/").slice(3).join("/"); - return `${ICXHeader}${term}/${category}/${name}%${baseName}` as FilePathWithPrefix; + const term = termOverRide || this.dependencies.getDeviceAndVaultName(); + return createCustomisationSyncV2DocumentPath(path, term, { + configDir: this.configDir, + useV2: this.useV2, + usePluginEtc: this.useSyncPluginEtc, + }); } unifiedKeyPrefixOfTerminal(termOverRide?: string) { - const term = termOverRide || this.services.setting.getDeviceAndVaultName(); - return `${ICXHeader}${term}/` as FilePathWithPrefix; + const term = termOverRide || this.dependencies.getDeviceAndVaultName(); + return createCustomisationSyncDevicePrefix(term); } parseUnifiedPath(unifiedPath: FilePathWithPrefix): { @@ -673,11 +589,7 @@ export class ConfigSync extends LiveSyncCommands { filename: string; pathV1: FilePathWithPrefix; } { - const [device, category, ...rest] = stripAllPrefixes(unifiedPath).split("/"); - const relativePath = rest.join("/"); - const [key, filename] = relativePath.split("%"); - const pathV1 = (unifiedPath.split("%")[0] + ".md") as FilePathWithPrefix; - return { device, category, key, filename, pathV1 }; + return parseCustomisationSyncV2DocumentPath(unifiedPath); } loadedManifest_mTime = new Map(); @@ -716,14 +628,17 @@ export class ConfigSync extends LiveSyncCommands { }; if (filename == "manifest.json") { // Same as previously loaded - if (this.loadedManifest_mTime.get(confKey) != file.mtime && pluginManifests.get(confKey) == undefined) { + if ( + this.loadedManifest_mTime.get(confKey) != file.mtime && + this.pluginManifests.get(confKey) == undefined + ) { try { const parsedManifest = JSON.parse(base64ToString(data)) as PluginManifest; - setManifest(confKey, parsedManifest); + this.setManifest(confKey, parsedManifest); this.pluginList .filter((e) => e instanceof PluginDataExDisplayV2 && e.confKey == confKey) .forEach((e) => (e as PluginDataExDisplayV2).applyLoadedManifest()); - pluginList.set(this.pluginList); + this.catalogue.set(this.pluginList); } catch (ex) { this._log( `The file ${loaded.path} seems to manifest, but could not be decoded as JSON`, @@ -736,7 +651,7 @@ export class ConfigSync extends LiveSyncCommands { this.pluginList .filter((e) => e instanceof PluginDataExDisplayV2 && e.confKey == confKey) .forEach((e) => (e as PluginDataExDisplayV2).applyLoadedManifest()); - pluginList.set(this.pluginList); + this.catalogue.set(this.pluginList); } // } } @@ -746,14 +661,17 @@ export class ConfigSync extends LiveSyncCommands { const { category, device, key, pathV1 } = this.parseUnifiedPath(unifiedPathV2); if (category == "") return; - const ret: PluginDataExDisplayV2 = new PluginDataExDisplayV2({ - documentPath: pathV1, - category: category, - name: key, - term: `${device}`, - files: [], - mtime: 0, - }); + const ret: PluginDataExDisplayV2 = new PluginDataExDisplayV2( + { + documentPath: pathV1, + category: category, + name: key, + term: `${device}`, + files: [], + mtime: 0, + }, + this.pluginManifests + ); return ret; } @@ -762,7 +680,7 @@ export class ConfigSync extends LiveSyncCommands { async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise { try { this.updatingV2Count++; - pluginV2Progress.set(this.updatingV2Count); + this.migrationProgress.set(this.updatingV2Count); // const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath); const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey); @@ -792,11 +710,11 @@ export class ConfigSync extends LiveSyncCommands { this.pluginList = newList; scheduleTask("updatePluginListV2", 100, () => { - pluginList.set(this.pluginList); + this.catalogue.set(this.pluginList); }); } finally { this.updatingV2Count--; - pluginV2Progress.set(this.updatingV2Count); + this.migrationProgress.set(this.updatingV2Count); } } @@ -839,7 +757,7 @@ export class ConfigSync extends LiveSyncCommands { const v2Path = (prefixPath + relativeFilename) as FilePathWithPrefix; // console.warn(`Migrating ${v1Path} / ${relativeFilename} to ${v2Path}`); this._log(`Migrating ${v1Path} / ${relativeFilename} to ${v2Path}`, LOG_LEVEL_VERBOSE); - const newId = await this.services.path.path2id(v2Path); + const newId = await this.path2id(v2Path); // const buf = const data = createBlob([DUMMY_HEAD, DUMMY_END, ...getDocDataAsArray(f.data)]); @@ -855,7 +773,7 @@ export class ConfigSync extends LiveSyncCommands { children: [], eden: {}, }; - const r = await this.core.localDatabase.putDBEntry(saving); + const r = await this.localDatabase.putDBEntry(saving); if (r && r.ok) { this._log(`Migrated ${v1Path} / ${f.filename} to ${v2Path}`, LOG_LEVEL_INFO); const delR = await this.deleteConfigOnDatabase(v1Path); @@ -872,12 +790,12 @@ export class ConfigSync extends LiveSyncCommands { if (!this.isThisModuleEnabled()) { this.pluginScanProcessor.clearQueue(); this.pluginList = []; - pluginList.set(this.pluginList); + this.catalogue.set(this.pluginList); return; } try { this.updatingV2Count++; - pluginV2Progress.set(this.updatingV2Count); + this.migrationProgress.set(this.updatingV2Count); const updatedDocumentId = updatedDocumentPath ? await this.path2id(updatedDocumentPath) : ""; const plugins = updatedDocumentPath ? this.localDatabase.findEntries(updatedDocumentId, updatedDocumentId + "\u{10ffff}", { @@ -898,11 +816,11 @@ export class ConfigSync extends LiveSyncCommands { this.pluginScanProcessor.enqueue(v); } } finally { - pluginIsEnumerating.set(false); + this.enumerationActive.set(false); this.updatingV2Count--; - pluginV2Progress.set(this.updatingV2Count); + this.migrationProgress.set(this.updatingV2Count); } - pluginIsEnumerating.set(false); + this.enumerationActive.set(false); // return entries; } async compareUsingDisplayData(dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach = false) { @@ -933,36 +851,18 @@ export class ConfigSync extends LiveSyncCommands { path = path.split("%")[1] as FilePath; } if (fileA.path.endsWith(".json")) { - return serialized( - "config:merge-data", - () => - new Promise((res) => { - this._log("Opening data-merging dialog", LOG_LEVEL_VERBOSE); - // const docs = [docA, docB]; - const modal = new JsonResolveModal( - this.app, - path, - [fileA, fileB], - async (keep, result) => { - if (result == null) return res(false); - try { - res(await this.applyData(dataA, result)); - } catch (ex) { - this._log("Could not apply merged file"); - this._log(ex, LOG_LEVEL_VERBOSE); - res(false); - } - }, - "Local", - `${dataB.term}`, - "B", - true, - true, - "Difference between local and remote" - ); - modal.open(); - }) - ); + return serialized("config:merge-data", async () => { + this._log("Opening data-merging dialog", LOG_LEVEL_VERBOSE); + return await this.dependencies.resolveJsonConflict(path, [fileA, fileB], dataB.term, async (result) => { + try { + return await this.applyData(dataA, result); + } catch (ex) { + this._log("Could not apply merged file"); + this._log(ex, LOG_LEVEL_VERBOSE); + return false; + } + }); + }); } else { const dmp = new diff_match_patch(); let docAData = getDocData(fileA.data); @@ -983,12 +883,8 @@ export class ConfigSync extends LiveSyncCommands { right: { rev: "B", ...fileB, data: docBData }, diff: diff, }; - // console.dir(diffResult); - const d = new ConflictResolveModal(this.app, path, diffResult, true, dataB.term); - d.open(); - const ret = await d.waitForResult(); - if (ret === CANCELLED) return false; - if (ret === LEAVE_TO_SUBSEQUENT) return false; + const ret = await this.dependencies.selectTextFile(path, diffResult, dataB.term); + if (ret === false) return false; const resultContent = ret == "A" ? docAData : ret == "B" ? docBData : undefined; if (resultContent) { return await this.applyData(dataA, resultContent); @@ -1004,10 +900,10 @@ export class ConfigSync extends LiveSyncCommands { const filename = data.files[0].filename; this._log(`Applying ${filename} of ${data.displayName || data.name}..`); const path = `${baseDir}/${filename}` as FilePath; - await this.core.storageAccess.ensureDir(path); + await this.storageAccess.ensureDir(path); // If the content has applied, modified time will be updated to the current time. - await this.core.storageAccess.writeHiddenFileAuto(path, content); - await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName()); + await this.storageAccess.writeHiddenFileAuto(path, content); + await this.storeCustomisationFileV2(path, this.dependencies.getDeviceAndVaultName()); } else { const files = data.files; for (const f of files) { @@ -1016,12 +912,12 @@ export class ConfigSync extends LiveSyncCommands { const path = `${baseDir}/${f.filename}` as FilePath; this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`); // const contentEach = createBlob(f.data); - await this.core.storageAccess.ensureDir(path); + await this.storageAccess.ensureDir(path); if (f.datatype == "newnote") { let oldData; try { - oldData = await this.core.storageAccess.readHiddenFileBinary(path); + oldData = await this.storageAccess.readHiddenFileBinary(path); } catch (ex) { this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE); @@ -1032,11 +928,11 @@ export class ConfigSync extends LiveSyncCommands { this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE); continue; } - await this.core.storageAccess.writeHiddenFileAuto(path, content, stat); + await this.storageAccess.writeHiddenFileAuto(path, content, stat); } else { let oldData; try { - oldData = await this.core.storageAccess.readHiddenFileText(path); + oldData = await this.storageAccess.readHiddenFileText(path); } catch (ex) { this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE); @@ -1047,10 +943,10 @@ export class ConfigSync extends LiveSyncCommands { this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE); continue; } - await this.core.storageAccess.writeHiddenFileAuto(path, content, stat); + await this.storageAccess.writeHiddenFileAuto(path, content, stat); } this._log(`Applied ${f.filename} of ${data.displayName || data.name}..`); - await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName()); + await this.storeCustomisationFileV2(path, this.dependencies.getDeviceAndVaultName()); } } } catch (ex) { @@ -1079,12 +975,12 @@ export class ConfigSync extends LiveSyncCommands { try { // console.dir(f); const path = `${baseDir}/${f.filename}`; - await this.core.storageAccess.ensureDir(path); + await this.storageAccess.ensureDir(path); if (!content) { const dt = decodeBinary(f.data); - await this.core.storageAccess.writeHiddenFileAuto(path, dt); + await this.storageAccess.writeHiddenFileAuto(path, dt); } else { - await this.core.storageAccess.writeHiddenFileAuto(path, content); + await this.storageAccess.writeHiddenFileAuto(path, content); } this._log(`Applying ${f.filename} of ${data.displayName || data.name}.. Done`); } catch (ex) { @@ -1098,28 +994,9 @@ export class ConfigSync extends LiveSyncCommands { await delay(100); this._log(`Config ${data.displayName || data.name} has been applied`, LOG_LEVEL_NOTICE); if (data.category == "PLUGIN_DATA" || data.category == "PLUGIN_MAIN") { - const pluginManager = getObsidianCommunityPluginManager(this.app); - const pluginManifest = pluginManager.manifests.find( - (manifest) => - pluginManager.enabledPlugins.has(manifest.id) && - manifest.dir == `${baseDir}/plugins/${data.name}` - ); - if (pluginManifest) { - this._log( - `Unloading plugin: ${pluginManifest.name}`, - LOG_LEVEL_NOTICE, - "plugin-reload-" + pluginManifest.id - ); - await pluginManager.unloadPlugin(pluginManifest.id); - await pluginManager.loadPlugin(pluginManifest.id); - this._log( - `Plugin reloaded: ${pluginManifest.name}`, - LOG_LEVEL_NOTICE, - "plugin-reload-" + pluginManifest.id - ); - } + await this.dependencies.reloadPlugin(baseDir, data.name); } else if (data.category == "CONFIG") { - this.services.appLifecycle.askRestart(); + this.dependencies.askRestart(); } return true; } catch (ex) { @@ -1170,28 +1047,10 @@ export class ConfigSync extends LiveSyncCommands { (docs as AnyEntry).path ? (docs as AnyEntry).path : this.getPath(docs as AnyEntry) ); } - if (this.isThisModuleEnabled() && this.core.settings.notifyPluginOrSettingUpdated) { - if (!this.pluginDialog || (this.pluginDialog && !this.pluginDialog.isOpened())) { - const fragment = createFragment((doc) => { - doc.createSpan(undefined, (a) => { - a.appendText(`Some configuration has been arrived, Press `); - a.appendChild( - a.createEl("a", undefined, (anchor) => { - anchor.text = "HERE"; - anchor.addEventListener("click", () => { - this.showPluginSyncModal(); - }); - }) - ); - - a.appendText(` to open the config sync dialog , or press elsewhere to dismiss this message.`); - }); - }); - + if (this.isThisModuleEnabled() && this.settings.notifyPluginOrSettingUpdated) { + if (!this.dependencies.getUIControl()?.isOpen()) { scheduleTask(UPDATED_CONFIGURATION_NOTICE_KEY, 1000, () => { - this.services.context.notices.show(UPDATED_CONFIGURATION_NOTICE_KEY, fragment, { - durationMs: 20_000, - }); + this.dependencies.showConfigurationNotice(() => this.showPluginSyncModal()); }); } } @@ -1215,13 +1074,13 @@ export class ConfigSync extends LiveSyncCommands { recentProcessedInternalFiles = [] as string[]; async makeEntryFromFile(path: FilePath): Promise { - const stat = await this.core.storageAccess.statHidden(path); + const stat = await this.storageAccess.statHidden(path); let version: string | undefined; let displayName: string | undefined; if (!stat) { return false; } - const contentBin = await this.core.storageAccess.readHiddenFileBinary(path); + const contentBin = await this.storageAccess.readHiddenFileBinary(path); let content: string[]; try { content = await arrayBufferToBase64(contentBin); @@ -1267,12 +1126,12 @@ export class ConfigSync extends LiveSyncCommands { const prefixedFileName = vf; const id = await this.path2id(prefixedFileName); - const stat = await this.core.storageAccess.statHidden(path); + const stat = await this.storageAccess.statHidden(path); if (!stat) { return false; } const mtime = stat.mtime; - const content = await this.core.storageAccess.readHiddenFileBinary(path); + const content = await this.storageAccess.readHiddenFileBinary(path); const contentBlob = createBlob([DUMMY_HEAD, DUMMY_END, ...(await arrayBufferToBase64(content))]); // const contentBlob = createBlob(content); try { @@ -1293,7 +1152,9 @@ export class ConfigSync extends LiveSyncCommands { eden: {}, }; } else { - if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) { + if ( + this.dependencies.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN + ) { this._log( `STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`, LOG_LEVEL_DEBUG @@ -1313,7 +1174,7 @@ export class ConfigSync extends LiveSyncCommands { `STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`, LOG_LEVEL_VERBOSE ); - this.services.path.markChangesAreSame(prefixedFileName, old.mtime, mtime + 1); + this.dependencies.path.markChangesAreSame(prefixedFileName, old.mtime, mtime + 1); return true; } saveData = { @@ -1339,7 +1200,7 @@ export class ConfigSync extends LiveSyncCommands { }); } async storeCustomizationFiles(path: FilePath, termOverRide?: string) { - const term = termOverRide || this.services.setting.getDeviceAndVaultName(); + const term = termOverRide || this.dependencies.getDeviceAndVaultName(); if (term == "") { this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE); return; @@ -1487,25 +1348,15 @@ export class ConfigSync extends LiveSyncCommands { } async watchVaultRawEventsAsync(path: FilePath) { - if (!this._isMainReady) return false; + if (!this._isMainReady()) return false; if (this._isMainSuspended()) return false; if (!this.isThisModuleEnabled()) return false; - // if (!this.isTargetPath(path)) return false; - const stat = await this.core.storageAccess.statHidden(path); + if (!this.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. if (stat && stat.type != "file") return false; - const configDir = normalizePath(this.configDir); - const synchronisedInConfigSync = Object.values(this.settings.pluginSyncExtendedSetting) - .filter((e) => e.mode != MODE_SELECTIVE && e.mode != MODE_SHINY) - .map((e) => e.files) - .flat() - .map((e) => `${configDir}/${e}`.toLowerCase()); - if (synchronisedInConfigSync.some((e) => e.startsWith(path.toLowerCase()))) { - this._log(`Customization file skipped: ${path}`, LOG_LEVEL_VERBOSE); - // This file could be handled by the other module. - return false; - } // this._log(`Customization file detected: ${path}`, LOG_LEVEL_VERBOSE); const storageMTime = ~~(((stat && stat.mtime) || 0) / 1000); const key = `${path}-${storageMTime}`; @@ -1529,7 +1380,7 @@ export class ConfigSync extends LiveSyncCommands { await shareRunningResult("scanAllConfigFiles", async () => { const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; this._log("Scanning customizing files.", logLevel, "scan-all-config"); - const term = this.services.setting.getDeviceAndVaultName(); + const term = this.dependencies.getDeviceAndVaultName(); if (term == "") { this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE); return; @@ -1557,9 +1408,11 @@ export class ConfigSync extends LiveSyncCommands { const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix; const localPath = localFileMap.get(unifiedFilenameWithKey); if (localPath) { - await this.storeCustomisationFileV2(localPath, term); + if (this.dependencies.ownsLocalFile(localPath)) { + await this.storeCustomisationFileV2(localPath, term); + } localFileMap.delete(unifiedFilenameWithKey); - } else { + } else if (this.dependencies.ownsLocalDocument(this.getPath(item))) { await this.deleteConfigOnDatabase(unifiedFilenameWithKey); } } catch (ex) { @@ -1574,6 +1427,7 @@ export class ConfigSync extends LiveSyncCommands { // Extra files const taskExtra = [] as (() => Promise)[]; for (const [, filePath] of localFileMap) { + if (!this.dependencies.ownsLocalFile(filePath)) continue; taskExtra.push(async () => { const releaser = await semaphore.acquire(); try { @@ -1611,11 +1465,15 @@ export class ConfigSync extends LiveSyncCommands { this._log(`scanAllConfigFiles - File not found: ${vp}`, LOG_LEVEL_VERBOSE); continue; } - await this.storeCustomizationFiles(p); + if (this.dependencies.ownsLocalFile(p)) { + await this.storeCustomizationFiles(p); + } deleteCandidate = deleteCandidate.filter((e) => e != vp); } for (const vp of deleteCandidate) { - await this.deleteConfigOnDatabase(vp); + if (this.dependencies.ownsLocalDocument(vp)) { + await this.deleteConfigOnDatabase(vp); + } } fireAndForget(() => this.updatePluginList(false)); } @@ -1677,25 +1535,25 @@ export class ConfigSync extends LiveSyncCommands { return Promise.resolve(false); } - private _allSuspendExtraSync(): Promise { - if (this.core.settings.usePluginSync || this.core.settings.autoSweepPlugins) { + _allSuspendExtraSync(): Promise { + if (this.settings.usePluginSync || this.settings.autoSweepPlugins) { this._log( "Customisation sync have been temporarily disabled. Please enable them after the fetching, if you need them.", LOG_LEVEL_NOTICE ); - this.core.settings.usePluginSync = false; - this.core.settings.autoSweepPlugins = false; + this.settings.usePluginSync = false; + this.settings.autoSweepPlugins = false; } return Promise.resolve(true); } - private async _allConfigureOptionalSyncFeature(mode: OptionalSyncFeatureMode) { + async _allConfigureOptionalSyncFeature(mode: OptionalSyncFeatureMode) { await this.configureHiddenFileSync(mode); return true; } async configureHiddenFileSync(mode: OptionalSyncFeatureMode) { if (mode == "DISABLE") { - await this.core.services.setting.applyPartial( + await this.dependencies.applySettings( { usePluginSync: false, }, @@ -1705,37 +1563,18 @@ export class ConfigSync extends LiveSyncCommands { } if (mode == "CUSTOMIZE") { - if (!this.services.setting.getDeviceAndVaultName()) { - let name = await this.core.confirm.askString( + if (!this.dependencies.getDeviceAndVaultName()) { + let name = await this.dependencies.askString( $msg("Device name"), $msg("Please set this device name"), `desktop` ); if (!name) { - if (Platform.isAndroidApp) { - name = "android-app"; - } else if (Platform.isIosApp) { - name = "ios"; - } else if (Platform.isMacOS) { - name = "macos"; - } else if (Platform.isMobileApp) { - name = "mobile-app"; - } else if (Platform.isMobile) { - name = "mobile"; - } else if (Platform.isSafari) { - name = "safari"; - } else if (Platform.isDesktop) { - name = "desktop"; - } else if (Platform.isDesktopApp) { - name = "desktop-app"; - } else { - name = "unknown"; - } - name = name + Math.random().toString(36).slice(-4); + name = this.dependencies.getFallbackDeviceName(); } - this.services.setting.setDeviceAndVaultName(name); + this.dependencies.setDeviceAndVaultName(name); } - await this.core.services.setting.applyPartial( + await this.dependencies.applySettings( { usePluginSync: true, useAdvancedMode: true, @@ -1750,9 +1589,9 @@ export class ConfigSync extends LiveSyncCommands { if (lastDepth == -1) return []; let w: ListedFiles; try { - w = await this.app.vault.adapter.list(path); + w = await this.dependencies.listFiles(path); } catch (ex) { - this._log(`Could not traverse(ConfigSync):${path}`, LOG_LEVEL_INFO); + this._log(`Could not traverse(CustomisationSync):${path}`, LOG_LEVEL_INFO); this._log(ex, LOG_LEVEL_VERBOSE); return []; } @@ -1762,16 +1601,4 @@ export class ConfigSync extends LiveSyncCommands { } return files; } - override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void { - services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this)); - services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this)); - services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this)); - services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this)); - services.appLifecycle.onResuming.addHandler(this._everyOnResumeProcess.bind(this)); - services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this)); - services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this)); - services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this)); - services.setting.suspendExtraSync.addHandler(this._allSuspendExtraSync.bind(this)); - services.setting.enableOptionalFeature.addHandler(this._allConfigureOptionalSyncFeature.bind(this)); - } } diff --git a/src/features/ConfigSync/customisationSyncContext.unit.fixture.ts b/src/features/ConfigSync/customisationSyncContext.unit.fixture.ts new file mode 100644 index 00000000..b49a2f9e --- /dev/null +++ b/src/features/ConfigSync/customisationSyncContext.unit.fixture.ts @@ -0,0 +1,49 @@ +import type { CustomisationSyncContextDependencies } from "./customisationSyncContext.ts"; + +/** Minimal inert dependency set for focused Customisation Sync unit tests. */ +export function createCustomisationSyncTestDependencies( + overrides: Partial = {} +): CustomisationSyncContextDependencies { + const defaults = { + getSettings: () => ({ + usePluginSync: true, + usePluginSyncV2: true, + usePluginEtc: true, + pluginSyncExtendedSetting: {}, + autoSweepPlugins: false, + autoSweepPluginsPeriodic: false, + watchInternalFileChanges: false, + notifyPluginOrSettingUpdated: false, + }), + getLocalDatabase: () => ({}), + storageAccess: {}, + path: {}, + log: () => undefined, + getConfigDir: () => ".config-dir", + getDeviceAndVaultName: () => "device-a", + setDeviceAndVaultName: () => undefined, + saveSettingData: () => Promise.resolve(), + applySettings: () => Promise.resolve(), + replicateUserInitiated: () => Promise.resolve(), + askString: () => Promise.resolve(false), + isReady: () => true, + isSuspended: () => false, + askRestart: () => undefined, + createPeriodicProcessor: () => ({ + enable: () => undefined, + disable: () => undefined, + }), + listFiles: () => Promise.resolve({ files: [], folders: [] }), + resolveJsonConflict: () => Promise.resolve(false), + selectTextFile: () => Promise.resolve(false), + reloadPlugin: () => Promise.resolve(), + getFallbackDeviceName: () => "desktop-test", + showConfigurationNotice: () => undefined, + hideConfigurationNotice: () => undefined, + getUIControl: () => undefined, + ownsLocalFile: () => true, + ownsLocalDocument: () => true, + publishScanCount: () => undefined, + } as unknown as CustomisationSyncContextDependencies; + return { ...defaults, ...overrides }; +} diff --git a/src/features/ConfigSync/customisationSyncContext.view.unit.spec.ts b/src/features/ConfigSync/customisationSyncContext.view.unit.spec.ts new file mode 100644 index 00000000..f1832453 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncContext.view.unit.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type FilePathWithPrefix, + MODE_AUTOMATIC, + MODE_SELECTIVE, + type PluginSyncSettingEntry, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + REPLICATION_PROGRESS_PRESENTATIONS, + USER_INITIATED_REPLICATION_AUTHORITY, +} from "@vrtmrz/livesync-commonlib/replication"; + +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"; +import type { IPluginDataExDisplay } from "./customisationSyncView.ts"; + +function createConfigSync() { + const saveSettingData = vi.fn(async () => undefined); + const replicateUserInitiated = vi.fn(async () => undefined); + const askString = vi.fn(async () => "device-b" as string | false); + const pluginSyncExtendedSetting: Record = { + "PLUGIN_DATA/example": { + key: "PLUGIN_DATA/example", + mode: MODE_AUTOMATIC, + files: ["plugins/example/data.json"], + }, + }; + const settings = { + usePluginSync: true, + usePluginSyncV2: true, + usePluginEtc: true, + pluginSyncExtendedSetting, + }; + const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext; + Object.assign(configSync, { + dependencies: createCustomisationSyncTestDependencies({ + getConfigDir: () => ".obsidian", + getSettings: () => settings as never, + saveSettingData, + replicateUserInitiated: replicateUserInitiated as never, + askString, + }), + }); + return { askString, configSync, replicateUserInitiated, saveSettingData, settings }; +} + +const display = { + documentPath: "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix, + category: "PLUGIN_DATA", + name: "example", + term: "device-a", + files: [ + { filename: "data.json", data: ["a"], mtime: 1, size: 1 }, + { filename: "other.json", data: ["b"], mtime: 2, size: 1 }, + ], + mtime: 2, +} satisfies IPluginDataExDisplay; + +describe("CustomisationSyncContext dialogue view", () => { + it("projects and updates Customisation Sync modes without exposing mutable settings", () => { + const { configSync, saveSettingData, settings } = createConfigSync(); + + const projected = configSync.getConfiguredModes(); + projected[0].files.push("changed-in-view"); + + expect(settings.pluginSyncExtendedSetting["PLUGIN_DATA/example"].files).toEqual(["plugins/example/data.json"]); + expect(configSync.getConfiguredTargetFiles("PLUGIN_DATA/example")).toEqual([ + ".obsidian/plugins/example/data.json", + ]); + + configSync.updateConfiguredMode("PLUGIN_MAIN/example", MODE_AUTOMATIC, ["plugins/example/main.js"]); + expect(settings.pluginSyncExtendedSetting["PLUGIN_MAIN/example"]).toEqual({ + key: "PLUGIN_MAIN/example", + mode: MODE_AUTOMATIC, + files: ["plugins/example/main.js"], + }); + + configSync.updateConfiguredMode("PLUGIN_MAIN/example", MODE_SELECTIVE, []); + expect(settings.pluginSyncExtendedSetting).not.toHaveProperty("PLUGIN_MAIN/example"); + expect(saveSettingData).toHaveBeenCalledTimes(2); + }); + + it("routes host operations through the focused view", async () => { + const { askString, configSync, replicateUserInitiated } = createConfigSync(); + + await configSync.synchronise(); + await expect(configSync.askString("Duplicate", "device name", "")).resolves.toBe("device-b"); + + expect(replicateUserInitiated).toHaveBeenCalledWith({ + trigger: "manual", + progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE, + interaction: USER_INITIATED_REPLICATION_AUTHORITY, + }); + expect(askString).toHaveBeenCalledWith("Duplicate", "device name", ""); + }); + + it("keeps file-level comparison clones and duplication inside the view boundary", async () => { + const { configSync } = createConfigSync(); + const compareUsingDisplayData = vi.fn< + (dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach?: boolean) => Promise + >(async () => true); + const storeCustomizationFiles = vi.fn(async () => true); + const updatePluginList = vi.fn(async () => undefined); + Object.assign(configSync, { + compareUsingDisplayData, + filenameToUnifiedKey: vi.fn(() => "ix:device-b/PLUGIN_DATA/example.md"), + storeCustomizationFiles, + updatePluginList, + }); + + await expect(configSync.compareFileUsingDisplayData(display, display, "data.json")).resolves.toBe(true); + const [left, right, compareEach] = compareUsingDisplayData.mock.calls[0]; + expect(left.files.map((file) => file.filename)).toEqual(["data.json"]); + expect(right.files.map((file) => file.filename)).toEqual(["data.json"]); + expect(compareEach).toBe(true); + expect(display.files).toHaveLength(2); + + await configSync.duplicateData(display, "device-b"); + expect(storeCustomizationFiles).toHaveBeenCalledWith(".obsidian/data.json", "device-b"); + expect(updatePluginList).toHaveBeenCalledWith(false, "ix:device-b/PLUGIN_DATA/example.md"); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncPaths.ts b/src/features/ConfigSync/customisationSyncPaths.ts new file mode 100644 index 00000000..71a5137a --- /dev/null +++ b/src/features/ConfigSync/customisationSyncPaths.ts @@ -0,0 +1,113 @@ +import { ICXHeader } from "@/common/types.ts"; +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; + +export type CustomisationSyncFileCategory = + | "CONFIG" + | "THEME" + | "SNIPPET" + | "PLUGIN_MAIN" + | "PLUGIN_ETC" + | "PLUGIN_DATA" + | ""; + +export type CustomisationSyncPathOptions = { + configDir: string; + useV2: boolean; + usePluginEtc: boolean; +}; + +export function getCustomisationSyncFileCategory( + filePath: string, + options: CustomisationSyncPathOptions +): CustomisationSyncFileCategory { + if (filePath.split("/").length == 2 && filePath.endsWith(".json")) return "CONFIG"; + if (filePath.split("/").length == 4 && filePath.startsWith(`${options.configDir}/themes/`)) return "THEME"; + if (filePath.startsWith(`${options.configDir}/snippets/`) && filePath.endsWith(".css")) return "SNIPPET"; + if (filePath.startsWith(`${options.configDir}/plugins/`)) { + if (filePath.endsWith("/styles.css") || filePath.endsWith("/manifest.json") || filePath.endsWith("/main.js")) { + return "PLUGIN_MAIN"; + } + if (filePath.endsWith("/data.json")) { + return "PLUGIN_DATA"; + } + return options.useV2 && options.usePluginEtc ? "PLUGIN_ETC" : ""; + } + return ""; +} + +export function isCustomisationSyncTargetPath(filePath: string, options: CustomisationSyncPathOptions): boolean { + if (!filePath.startsWith(options.configDir)) return false; + return getCustomisationSyncFileCategory(filePath, options) != ""; +} + +export function getCustomisationSyncSettingKey( + filePath: string, + options: CustomisationSyncPathOptions +): string | undefined { + if (!isCustomisationSyncTargetPath(filePath, options)) return undefined; + const category = getCustomisationSyncFileCategory(filePath, options); + const name = + category == "CONFIG" || category == "SNIPPET" + ? filePath.split("/").slice(-1)[0] + : filePath.split("/").slice(-2)[0]; + return name ? `${category}/${name}` : undefined; +} + +export function getCustomisationSyncSettingKeyFromDocumentPath(documentPath: FilePathWithPrefix): string | undefined { + const [, category, ...rest] = stripAllPrefixes(documentPath).split("/"); + if (!category || rest.length == 0) return undefined; + if (!["CONFIG", "THEME", "SNIPPET", "PLUGIN_MAIN", "PLUGIN_ETC", "PLUGIN_DATA"].includes(category)) { + return undefined; + } + const encodedName = category == "CONFIG" || category == "SNIPPET" ? rest.join("/") : rest[0]; + const name = encodedName.split("%")[0].replace(/\.md$/, ""); + return name ? `${category}/${name}` : undefined; +} + +export function createCustomisationSyncV1DocumentPath( + filePath: string, + device: string, + options: CustomisationSyncPathOptions +): FilePathWithPrefix { + const category = getCustomisationSyncFileCategory(filePath, options); + const name = + category == "CONFIG" || category == "SNIPPET" + ? filePath.split("/").slice(-1)[0] + : category == "PLUGIN_ETC" + ? filePath.split("/").slice(-2).join("/") + : filePath.split("/").slice(-2)[0]; + return `${ICXHeader}${device}/${category}/${name}.md` as FilePathWithPrefix; +} + +export function createCustomisationSyncV2DocumentPath( + filePath: string, + device: string, + options: CustomisationSyncPathOptions +): FilePathWithPrefix { + const category = getCustomisationSyncFileCategory(filePath, options); + const name = + category == "CONFIG" || category == "SNIPPET" + ? filePath.split("/").slice(-1)[0] + : filePath.split("/").slice(-2)[0]; + const baseName = category == "CONFIG" || category == "SNIPPET" ? name : filePath.split("/").slice(3).join("/"); + return `${ICXHeader}${device}/${category}/${name}%${baseName}` as FilePathWithPrefix; +} + +export function createCustomisationSyncDevicePrefix(device: string): FilePathWithPrefix { + return `${ICXHeader}${device}/` as FilePathWithPrefix; +} + +export function parseCustomisationSyncV2DocumentPath(unifiedPath: FilePathWithPrefix): { + category: string; + device: string; + key: string; + filename: string; + pathV1: FilePathWithPrefix; +} { + const [device, category, ...rest] = stripAllPrefixes(unifiedPath).split("/"); + const relativePath = rest.join("/"); + const [key, filename] = relativePath.split("%"); + const pathV1 = (unifiedPath.split("%")[0] + ".md") as FilePathWithPrefix; + return { device, category, key, filename, pathV1 }; +} diff --git a/src/features/ConfigSync/customisationSyncPaths.unit.spec.ts b/src/features/ConfigSync/customisationSyncPaths.unit.spec.ts new file mode 100644 index 00000000..ecd2719d --- /dev/null +++ b/src/features/ConfigSync/customisationSyncPaths.unit.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +import { + createCustomisationSyncDevicePrefix, + createCustomisationSyncV1DocumentPath, + createCustomisationSyncV2DocumentPath, + getCustomisationSyncFileCategory, + isCustomisationSyncTargetPath, + getCustomisationSyncSettingKey, + getCustomisationSyncSettingKeyFromDocumentPath, + parseCustomisationSyncV2DocumentPath, + type CustomisationSyncPathOptions, +} from "./customisationSyncPaths.ts"; + +const currentOptions: CustomisationSyncPathOptions = { + configDir: ".obsidian", + useV2: true, + usePluginEtc: true, +}; + +describe("compatibility: Customisation Sync path operations", () => { + it.each([ + [".obsidian/app.json", "CONFIG"], + [".obsidian/themes/Minimal/theme.css", "THEME"], + [".obsidian/snippets/example.css", "SNIPPET"], + [".obsidian/plugins/example/manifest.json", "PLUGIN_MAIN"], + [".obsidian/plugins/example/data.json", "PLUGIN_DATA"], + [".obsidian/plugins/example/extra.json", "PLUGIN_ETC"], + ] as const)("classifies the maintained path %s as %s", (path, expected) => { + expect(getCustomisationSyncFileCategory(path, currentOptions)).toBe(expected); + }); + + it("preserves the exact depth and case-sensitive category rules", () => { + expect(getCustomisationSyncFileCategory(".obsidian/themes/Minimal/assets/theme.css", currentOptions)).toBe(""); + expect(getCustomisationSyncFileCategory(".obsidian/snippets/example.CSS", currentOptions)).toBe(""); + expect(getCustomisationSyncFileCategory(".Obsidian/plugins/example/main.js", currentOptions)).toBe(""); + }); + + it("requires both V2 and plug-in-extra support for other plug-in files", () => { + const path = ".obsidian/plugins/example/extra.json"; + + expect(getCustomisationSyncFileCategory(path, { ...currentOptions, useV2: false })).toBe(""); + expect(getCustomisationSyncFileCategory(path, { ...currentOptions, usePluginEtc: false })).toBe(""); + }); + + it("keeps category classification separate from configuration-directory targeting", () => { + expect(getCustomisationSyncFileCategory("notes/example.json", currentOptions)).toBe("CONFIG"); + expect(isCustomisationSyncTargetPath("notes/example.json", currentOptions)).toBe(false); + expect(isCustomisationSyncTargetPath(".obsidian/app.json", currentOptions)).toBe(true); + }); + + it.each([ + [".obsidian/app.json", "CONFIG/app.json"], + [".obsidian/themes/Minimal/theme.css", "THEME/Minimal"], + [".obsidian/snippets/example.css", "SNIPPET/example.css"], + [".obsidian/plugins/example/main.js", "PLUGIN_MAIN/example"], + [".obsidian/plugins/example/data.json", "PLUGIN_DATA/example"], + [".obsidian/plugins/example/extra.json", "PLUGIN_ETC/example"], + ] as const)("maps the local path %s to setting key %s", (path, expected) => { + expect(getCustomisationSyncSettingKey(path, currentOptions)).toBe(expected); + }); + + it.each([ + ["ix:device-a/CONFIG/app.json.md", "CONFIG/app.json"], + ["ix:device-a/CONFIG/app.json%app.json", "CONFIG/app.json"], + ["ix:device-a/THEME/Minimal.md", "THEME/Minimal"], + ["ix:device-a/PLUGIN_DATA/example%data.json", "PLUGIN_DATA/example"], + ["ix:device-a/PLUGIN_ETC/example/extra.json.md", "PLUGIN_ETC/example"], + ] as const)("maps the persisted path %s to setting key %s", (path, expected) => { + expect(getCustomisationSyncSettingKeyFromDocumentPath(path as FilePathWithPrefix)).toBe(expected); + }); + + it.each([ + [".obsidian/app.json", "ix:device-a/CONFIG/app.json.md"], + [".obsidian/themes/Minimal/theme.css", "ix:device-a/THEME/Minimal.md"], + [".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example.md"], + [".obsidian/plugins/example/extra.json", "ix:device-a/PLUGIN_ETC/example/extra.json.md"], + ] as const)("creates the persisted V1 path for %s", (path, expected) => { + expect(createCustomisationSyncV1DocumentPath(path, "device-a", currentOptions)).toBe(expected); + }); + + it.each([ + [".obsidian/app.json", "ix:device-a/CONFIG/app.json%app.json"], + [".obsidian/themes/Minimal/theme.css", "ix:device-a/THEME/Minimal%theme.css"], + [".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example%main.js"], + [".obsidian/plugins/example/extra.json", "ix:device-a/PLUGIN_ETC/example%extra.json"], + ] as const)("creates the persisted V2 path for %s", (path, expected) => { + expect(createCustomisationSyncV2DocumentPath(path, "device-a", currentOptions)).toBe(expected); + }); + + it("creates and parses device-scoped V2 paths", () => { + expect(createCustomisationSyncDevicePrefix("device-a")).toBe("ix:device-a/"); + expect( + parseCustomisationSyncV2DocumentPath("ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix) + ).toEqual({ + device: "device-a", + category: "PLUGIN_MAIN", + key: "example", + filename: "main.js", + pathV1: "ix:device-a/PLUGIN_MAIN/example.md", + }); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncUIBoundary.unit.spec.ts b/src/features/ConfigSync/customisationSyncUIBoundary.unit.spec.ts new file mode 100644 index 00000000..47c425a4 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncUIBoundary.unit.spec.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const uiSources = [ + ["PluginDialogModal", readFileSync(new URL("./PluginDialogModal.ts", import.meta.url), "utf8")], + ["PluginPane", readFileSync(new URL("./PluginPane.svelte", import.meta.url), "utf8")], + ["PluginCombo", readFileSync(new URL("./PluginCombo.svelte", import.meta.url), "utf8")], + [ + "PaneCustomisationSync", + readFileSync( + new URL("../../modules/features/SettingDialogue/PaneCustomisationSync.ts", import.meta.url), + "utf8" + ), + ], + [ + "PaneHatch", + readFileSync(new URL("../../modules/features/SettingDialogue/PaneHatch.ts", import.meta.url), "utf8"), + ], +] as const; +const customisationSyncSource = readFileSync(new URL("./customisationSyncContext.ts", import.meta.url), "utf8"); +const settingTabSource = readFileSync( + new URL("../../modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts", import.meta.url), + "utf8" +); +const settingModuleSource = readFileSync( + new URL("../../modules/features/ModuleObsidianSettingTab.ts", import.meta.url), + "utf8" +); + +describe("optional-file synchronisation UI dependency boundary", () => { + it.each(uiSources)("keeps %s independent from concrete add-ons and the application core", (_name, source) => { + expect(source).not.toMatch(/from ["'][^"']*Cmd(?:Config|HiddenFile)Sync(?:\.ts)?["']/); + expect(source).not.toMatch(/from ["'][^"']*main(?:\.ts)?["']/); + expect(source).not.toContain("getAddOn("); + expect(source).not.toContain("getAddOn<"); + }); + + it("keeps the Customisation Sync runtime independent from its Obsidian dialogue", () => { + expect(customisationSyncSource).not.toMatch(/from ["'][^"']*PluginDialogModal(?:\.ts)?["']/); + }); + + it("keeps the settings presentation out of the runtime cycle and off the compatibility lookup", () => { + expect(settingTabSource).not.toMatch(/^import(?!\s+type\b)[^;]*from ["'][^"']*main(?:\.ts)?["'];/mu); + expect(settingModuleSource).not.toContain("getAddOn("); + expect(settingModuleSource).not.toContain("getAddOn<"); + }); +}); diff --git a/src/features/ConfigSync/customisationSyncView.ts b/src/features/ConfigSync/customisationSyncView.ts new file mode 100644 index 00000000..d0c664c1 --- /dev/null +++ b/src/features/ConfigSync/customisationSyncView.ts @@ -0,0 +1,75 @@ +import type { PluginManifest } from "@/deps.ts"; +import type { + FilePathWithPrefix, + LoadedEntry, + PluginSyncSettingEntry, + SYNC_MODE, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { Readable } from "svelte/store"; + +import type { PluginDataExFile } from "./customisationSyncCodec.ts"; + +export type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile; + +export interface IPluginDataExDisplay { + documentPath: FilePathWithPrefix; + category: string; + name: string; + term: string; + displayName?: string; + files: (LoadedEntryPluginDataExFile | PluginDataExFile)[]; + version?: string; + mtime: number; +} + +export type PluginDataExDisplay = { + documentPath: FilePathWithPrefix; + category: string; + name: string; + term: string; + displayName?: string; + files: PluginDataExFile[]; + version?: string; + mtime: number; +}; + +/** Stable catalogue and operation surface consumed by the Obsidian dialogue. */ +export interface CustomisationSyncDialogView { + readonly catalogue: Readable; + readonly enumerationActive: Readable; + readonly migrationProgress: Readable; + readonly manifests: Readable>; + + isEnabled(): boolean; + getDeviceAndVaultName(): string; + getConfiguredModes(): PluginSyncSettingEntry[]; + isPluginEtcEnabled(): boolean; + updateConfiguredMode(key: string, mode: SYNC_MODE, files: string[]): void; + getConfiguredTargetFiles(key: string): string[]; + + updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise; + reloadPluginList(showMessage: boolean): Promise; + scanAllConfigFiles(showMessage: boolean): Promise; + synchronise(): Promise; + applyData(data: IPluginDataExDisplay): Promise; + compareUsingDisplayData( + dataA: IPluginDataExDisplay, + dataB: IPluginDataExDisplay, + compareEach?: boolean + ): Promise; + compareFileUsingDisplayData( + dataA: IPluginDataExDisplay, + dataB: IPluginDataExDisplay, + filename: string + ): Promise; + deleteData(data: IPluginDataExDisplay): Promise; + duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise; + askString(title: string, key: string, placeholder: string): Promise; +} + +/** Narrow control returned by the host-owned dialogue composition feature. */ +export interface CustomisationSyncUIControl { + open(): void; + close(): void; + isOpen(): boolean; +} diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts deleted file mode 100644 index ad664a0f..00000000 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts +++ /dev/null @@ -1,470 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - type DocumentID, - LOG_LEVEL_NOTICE, - type FilePath, - type FilePathWithPrefix, - type MetaEntry, - type UXFileInfo, -} from "@vrtmrz/livesync-commonlib/compat/common/types"; - -vi.mock("@/deps.ts", () => ({})); -vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ - JsonResolveModal: class JsonResolveModal {}, -})); -vi.mock("@/features/LiveSyncCommands.ts", () => ({ - LiveSyncCommands: class LiveSyncCommands { - plugin!: { app: unknown }; - core!: { services: unknown; settings: unknown }; - get app() { - return this.plugin.app; - } - get services() { - return this.core.services; - } - get settings() { - return this.core.settings; - } - }, -})); -vi.mock("./configureHiddenFileSyncMode.ts", () => ({ - configureHiddenFileSyncMode: vi.fn(), -})); - -import { HiddenFileSync } from "./CmdHiddenFileSync.ts"; -import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts"; - -function createHiddenRevisionOperation() { - const path = ".obsidian/plugins/example/data.json" as FilePath; - const file = { - path, - name: "data.json", - isInternal: true, - body: new Blob(["{\"value\":\"vault\"}"]), - stat: { - ctime: 1, - mtime: 2, - size: 17, - type: "file", - }, - } as UXFileInfo; - const selected = { - _id: "i:example" as DocumentID, - _rev: "2-selected", - path: `i:${path}` as FilePathWithPrefix, - ctime: 1, - mtime: 2, - size: 17, - type: "plain", - datatype: "plain", - children: [], - eden: {}, - deleted: false, - } as MetaEntry; - const winner = { - ...selected, - _rev: "3-winner", - } as MetaEntry; - const databaseFileAccess = { - fetchEntryMeta: vi.fn( - async (_path: unknown, revision?: string) => - revision === selected._rev ? selected : winner - ), - getConflictedRevs: vi.fn(async () => [selected._rev]), - fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: "{\"value\":\"database\"}" })), - storeWithBaseRevision: vi.fn(async () => "3-vault-child"), - }; - const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; - Object.assign(hiddenFileSync, { - core: { - services: { - vault: { - isIgnoredByIgnoreFile: vi.fn(async () => false), - }, - }, - databaseFileAccess, - }, - loadFileWithInfo: vi.fn(async () => file), - updateLastProcessed: vi.fn(), - _log: vi.fn(), - }); - return { - hiddenFileSync, - path, - file, - selected, - winner, - databaseFileAccess, - }; -} - -describe("HiddenFileSync configuration-change notices", () => { - it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => { - const commands: Array<{ - id: string; - checkCallback?: (checking: boolean) => boolean | void; - }> = []; - const settings = { - syncInternalFiles: false, - useAdvancedMode: false, - }; - const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; - Object.assign(hiddenFileSync, { - core: { - settings, - services: { - API: { - addCommand: vi.fn((command) => commands.push(command)), - }, - }, - }, - _isMainReady: vi.fn(() => true), - _isMainSuspended: vi.fn(() => false), - _isDatabaseReady: vi.fn(() => true), - }); - - hiddenFileSync.onload(); - - const commandIds = [ - "livesync-sync-internal", - "livesync-scaninternal-storage", - "livesync-scaninternal-database", - "livesync-internal-scan-offline-changes", - ]; - for (const commandId of commandIds) { - const command = commands.find(({ id }) => id === commandId); - expect(command?.checkCallback?.(true)).toBe(false); - } - - settings.syncInternalFiles = true; - settings.useAdvancedMode = true; - for (const commandId of commandIds) { - const command = commands.find(({ id }) => id === commandId); - expect(command?.checkCallback?.(true)).toBe(true); - } - }); - - it("does not report Hidden File Sync as ready before the main runtime is ready", () => { - const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; - Object.assign(hiddenFileSync, { - core: { - settings: { - syncInternalFiles: true, - }, - }, - _isMainReady: vi.fn(() => false), - _isMainSuspended: vi.fn(() => false), - }); - - expect(hiddenFileSync.isReady()).toBe(false); - }); - - it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => { - const noticeGroups = { - setItem: vi.fn(), - finish: vi.fn(() => true), - removeItem: vi.fn(() => true), - }; - const plugin = { - app: { - plugins: { - manifests: { - alpha: { - id: "alpha", - name: "Alpha", - dir: ".obsidian/plugins/alpha", - }, - beta: { - id: "beta", - name: "Beta", - dir: ".obsidian/plugins/beta", - }, - }, - enabledPlugins: new Set(["alpha", "beta"]), - unloadPlugin: vi.fn(async () => undefined), - loadPlugin: vi.fn(async () => undefined), - }, - }, - }; - const core = { - confirm: { askInPopup: vi.fn() }, - services: { - context: { noticeGroups }, - API: { getSystemConfigDir: vi.fn(() => ".obsidian") }, - appLifecycle: { - isReloadingScheduled: vi.fn(() => false), - scheduleRestart: vi.fn(), - }, - }, - }; - const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; - Object.assign(hiddenFileSync, { - plugin, - core, - queuedNotificationFiles: new Set([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]), - _log: vi.fn(), - }); - - hiddenFileSync.notifyConfigChange(); - - expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "hidden-file-changes", "plugin:alpha", { - message: "Files in Alpha were updated.", - action: expect.objectContaining({ label: "Reload Alpha" }), - }); - expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "hidden-file-changes", "plugin:beta", { - message: "Files in Beta were updated.", - action: expect.objectContaining({ label: "Reload Beta" }), - }); - expect(noticeGroups.setItem).toHaveBeenNthCalledWith(3, "hidden-file-changes", "restart", { - message: "Other Obsidian settings files were updated.", - action: expect.objectContaining({ label: "Schedule an Obsidian restart" }), - }); - expect(noticeGroups.setItem.mock.calls.every(([groupKey]) => groupKey === "hidden-file-changes")).toBe(true); - expect(noticeGroups.finish).toHaveBeenCalledWith("hidden-file-changes", { durationMs: 20_000 }); - expect(core.confirm.askInPopup).not.toHaveBeenCalled(); - - const reloadAction = (noticeGroups.setItem.mock.calls[0]?.[2] as { action: { onSelect: () => void } }).action - .onSelect; - reloadAction(); - await vi.waitFor(() => { - expect(plugin.app.plugins.unloadPlugin).toHaveBeenCalledWith("alpha"); - expect(plugin.app.plugins.loadPlugin).toHaveBeenCalledWith("alpha"); - expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "plugin:alpha"); - }); - - const restartAction = (noticeGroups.setItem.mock.calls[2]?.[2] as { action: { onSelect: () => void } }).action - .onSelect; - restartAction(); - expect(core.services.appLifecycle.scheduleRestart).toHaveBeenCalledOnce(); - expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart"); - }); - - it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => { - const progress = { - log: vi.fn(), - once: vi.fn(), - done: vi.fn(), - }; - const rebuildMerging = vi.fn(async () => []); - const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined); - const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined); - const scanAllStorageChanges = vi.fn(async () => undefined); - const scanAllDatabaseChanges = vi.fn(async () => undefined); - const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; - Object.assign(hiddenFileSync, { - _progress: vi.fn(() => progress), - rebuildMerging, - adoptCurrentStorageFilesAsProcessed, - adoptCurrentDatabaseFilesAsProcessed, - scanAllStorageChanges, - scanAllDatabaseChanges, - }); - - await hiddenFileSync.initialiseInternalFileSync("safe", true); - - expect(rebuildMerging).toHaveBeenCalledWith(false, false); - expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false); - expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false); - expect(progress.done).toHaveBeenCalledOnce(); - }); - - it("retirement guard: does not restore separate gathering and restart Notices", async () => { - vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => { - await handlers.enable(); - await handlers.initialise("safe"); - return "enabled"; - }); - const events: string[] = []; - const progress = { - log: vi.fn((message: string) => { - events.push(`progress:${message}`); - }), - once: vi.fn(), - done: vi.fn(), - }; - const createProgress = vi.fn(() => progress); - const applyPartial = vi.fn(async () => { - events.push("apply-settings"); - }); - const initialiseInternalFileSync = vi.fn(async () => undefined); - const log = vi.fn(); - const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; - Object.assign(hiddenFileSync, { - core: { - services: { - setting: { applyPartial }, - }, - }, - initialiseInternalFileSync, - _progress: createProgress, - _log: log, - }); - - await hiddenFileSync.configureHiddenFileSync("MERGE"); - - expect(createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE); - expect(events[0]).toBe("progress:Preparing Hidden File Sync..."); - expect(initialiseInternalFileSync).toHaveBeenCalledWith("safe", true, false, progress); - expect(log).not.toHaveBeenCalledWith("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE); - expect(log).not.toHaveBeenCalledWith("Done! Restarting the app is strongly recommended!", LOG_LEVEL_NOTICE); - expect(log).toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number)); - }); - - it("closes the preparation Notice when enabling Hidden File Sync fails", async () => { - vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => { - await handlers.enable(); - return "enabled"; - }); - const error = new Error("setting persistence failed"); - const progress = { - log: vi.fn(), - once: vi.fn(), - done: vi.fn(), - }; - const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; - Object.assign(hiddenFileSync, { - core: { - services: { - setting: { - applyPartial: vi.fn(async () => { - throw error; - }), - }, - }, - }, - _progress: vi.fn(() => progress), - _log: vi.fn(), - }); - - await expect(hiddenFileSync.configureHiddenFileSync("MERGE")).rejects.toBe(error); - - expect(progress.done).toHaveBeenCalledWith("Failed"); - }); -}); - -describe("HiddenFileSync exact revision repair operations", () => { - it("stores the current hidden Vault file as a child of the selected live revision", async () => { - const { - hiddenFileSync, - file, - selected, - databaseFileAccess, - } = createHiddenRevisionOperation(); - - await expect( - hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!) - ).resolves.toBe(true); - - expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith( - expect.objectContaining({ - path: file.path, - body: file.body, - isInternal: true, - }), - selected._rev, - true - ); - expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith( - file.path, - expect.objectContaining({ _rev: "3-vault-child" }), - file.stat - ); - }); - - it("refuses to extend a hidden-file revision which is no longer live", async () => { - const { - hiddenFileSync, - file, - selected, - databaseFileAccess, - } = createHiddenRevisionOperation(); - databaseFileAccess.getConflictedRevs.mockResolvedValue([]); - - await expect( - hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!) - ).resolves.toBe(false); - - expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); - expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled(); - }); - - it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => { - const { - hiddenFileSync, - file, - selected, - databaseFileAccess, - } = createHiddenRevisionOperation(); - - await expect( - hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision( - file, - selected._rev!, - false - ) - ).resolves.toBe(false); - - expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); - expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled(); - }); - - it("marks a matching hidden-file revision without creating a child", async () => { - const { - hiddenFileSync, - file, - selected, - databaseFileAccess, - } = createHiddenRevisionOperation(); - databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({ - ...selected, - data: "{\"value\":\"vault\"}", - }); - - await expect( - hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision( - file, - selected._rev!, - false - ) - ).resolves.toBe(true); - - expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); - expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith( - file.path, - selected, - file.stat - ); - }); - - it("applies the selected live hidden-file revision through the existing extraction path", async () => { - const { - hiddenFileSync, - path, - selected, - } = createHiddenRevisionOperation(); - const extract = vi.fn(async () => true); - hiddenFileSync.extractInternalFileFromDatabase = extract; - - await expect( - hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true) - ).resolves.toBe(true); - - expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev); - }); - - it("does not apply a hidden-file revision which ceased to be live", async () => { - const { - hiddenFileSync, - path, - selected, - databaseFileAccess, - } = createHiddenRevisionOperation(); - databaseFileAccess.getConflictedRevs.mockResolvedValue([]); - - await expect( - hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true) - ).resolves.toBe(false); - - expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled(); - }); -}); diff --git a/src/features/HiddenFileSync/hiddenFileSyncContext.routing.unit.spec.ts b/src/features/HiddenFileSync/hiddenFileSyncContext.routing.unit.spec.ts new file mode 100644 index 00000000..cb727971 --- /dev/null +++ b/src/features/HiddenFileSync/hiddenFileSyncContext.routing.unit.spec.ts @@ -0,0 +1,67 @@ +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 createHiddenFileSync( + options: { owned?: boolean; ignoredByIgnoreFile?: boolean; patternMatch?: boolean } = {} +) { + const ownsLocalFile = vi.fn(() => options.owned ?? true); + const isIgnoredByIgnoreFile = vi.fn(async () => options.ignoredByIgnoreFile ?? false); + const isTargetFileInPatterns = vi.fn(() => options.patternMatch ?? true); + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + Object.assign(hiddenFileSync, { + dependencies: { ownsLocalFile, isIgnoredByIgnoreFile }, + isTargetFileInPatterns, + }); + return { hiddenFileSync, isIgnoredByIgnoreFile, isTargetFileInPatterns, ownsLocalFile }; +} + +describe("Hidden File Sync local-path admission", () => { + it("checks composition ownership before Hidden File Sync filters", async () => { + const { hiddenFileSync, isTargetFileInPatterns, ownsLocalFile } = createHiddenFileSync({ owned: false }); + + await expect(hiddenFileSync.isTargetFile(PATH)).resolves.toBe(false); + expect(ownsLocalFile).toHaveBeenCalledWith(PATH); + expect(isTargetFileInPatterns).not.toHaveBeenCalled(); + }); + + it("keeps target patterns and ignore-file results as Hidden File Sync eligibility", async () => { + const patternExcluded = createHiddenFileSync({ patternMatch: false }); + await expect(patternExcluded.hiddenFileSync.isTargetFile(PATH)).resolves.toBe(false); + expect(patternExcluded.isIgnoredByIgnoreFile).not.toHaveBeenCalled(); + + const ignoreFileExcluded = createHiddenFileSync({ ignoredByIgnoreFile: true }); + await expect(ignoreFileExcluded.hiddenFileSync.isTargetFile(PATH)).resolves.toBe(false); + expect(ignoreFileExcluded.isIgnoredByIgnoreFile).toHaveBeenCalledWith(PATH); + + const admitted = createHiddenFileSync(); + await expect(admitted.hiddenFileSync.isTargetFile(PATH)).resolves.toBe(true); + }); + + it("exposes eligibility without consulting the composition owner", async () => { + const { hiddenFileSync, ownsLocalFile } = createHiddenFileSync({ owned: false }); + + await expect(hiddenFileSync.isTargetFileEligible(PATH)).resolves.toBe(true); + expect(ownsLocalFile).not.toHaveBeenCalled(); + }); +}); + +describe("compatibility: Hidden File Sync path shape", () => { + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + + it.each([ + [".obsidian/app.json", true], + [".trash/app.json", false], + ["notes/app.json", false], + ])("recognises %s as a Hidden File Sync path=%s", (path, expected) => { + expect(hiddenFileSync.isHiddenFileSyncHandlingPath(path as FilePath)).toBe(expected); + }); +}); diff --git a/src/features/HiddenFileSync/hiddenFileSyncContext.state.unit.spec.ts b/src/features/HiddenFileSync/hiddenFileSyncContext.state.unit.spec.ts new file mode 100644 index 00000000..ec547e3a --- /dev/null +++ b/src/features/HiddenFileSync/hiddenFileSyncContext.state.unit.spec.ts @@ -0,0 +1,92 @@ +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"; + +function createContext() { + const periodicProcessor = { enable: vi.fn(), disable: vi.fn() }; + const publishActivity = vi.fn(); + const hideConfigurationChangeNotice = vi.fn(); + const context = new HiddenFileSyncContext({ + createPeriodicProcessor: vi.fn(() => periodicProcessor), + publishActivity, + closeJsonConflictDialogs: vi.fn(), + hideConfigurationChangeNotice, + } as never); + return { context, hideConfigurationChangeNotice, periodicProcessor, publishActivity }; +} + +describe("HiddenFileSyncContext state ownership", () => { + it("owns queues, caches, concurrency controls, and processors per context instance", () => { + const first = createContext(); + const second = createContext(); + + first.context.pendingConflictChecks.add("i:.obsidian/first.json" as never); + first.context.queuedNotificationFiles.add(".obsidian/plugins/first"); + first.context.cacheFileRegExps.set("first", []); + + expect(second.context.pendingConflictChecks).toEqual(new Set()); + expect(second.context.queuedNotificationFiles).toEqual(new Set()); + expect(second.context.cacheFileRegExps).toEqual(new Map()); + expect(first.context.conflictResolutionProcessor).not.toBe(second.context.conflictResolutionProcessor); + expect(first.context.semaphore).not.toBe(second.context.semaphore); + expect(first.context.periodicInternalFileScanProcessor).toBe(first.periodicProcessor); + expect(second.context.periodicInternalFileScanProcessor).toBe(second.periodicProcessor); + + first.context.dispose(); + second.context.dispose(); + }); + + it("publishes instance-owned event and processing counts at each transition", async () => { + const { context, publishActivity } = createContext(); + const path = ".obsidian/app.json" as FilePath; + + await context.serializedForEvent(path, async () => { + expect(publishActivity).toHaveBeenLastCalledWith(1, 1); + }); + + expect(publishActivity.mock.calls).toEqual([ + [1, 0], + [1, 1], + [1, 0], + [0, 0], + ]); + context.dispose(); + }); + + it.each([ + [new Map(), true], + [new Map([[".obsidian/app.json", "1-2-3"]]), false], + ])( + "preserves start-up scan notice selection for the processed-file cache", + async (processedFiles, forcedNotice) => { + const { context } = createContext(); + const performStartupScan = vi.fn(async () => undefined); + const keyValueDatabase = { + get: vi.fn(async (key: IDBValidKey) => { + if (key == "hidden-file-lastProcessed") return processedFiles; + return new Map(); + }), + }; + Object.assign(context, { + dependencies: { + createPeriodicProcessor: vi.fn(), + getKeyValueDatabase: () => keyValueDatabase, + getSettings: () => ({ syncInternalFiles: true }), + log: vi.fn(), + }, + performStartupScan, + }); + + await context._everyOnDatabaseInitialized(false); + + expect(performStartupScan).toHaveBeenCalledWith(forcedNotice); + context.conflictResolutionProcessor.terminate(); + } + ); +}); diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.ts b/src/features/HiddenFileSync/hiddenFileSyncContext.ts similarity index 79% rename from src/features/HiddenFileSync/CmdHiddenFileSync.ts rename to src/features/HiddenFileSync/hiddenFileSyncContext.ts index 5b945c69..a2e24d14 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.ts +++ b/src/features/HiddenFileSync/hiddenFileSyncContext.ts @@ -1,13 +1,11 @@ -import { type ListedFiles } from "@/deps.ts"; import { + type AnyEntry, type LoadedEntry, type FilePathWithPrefix, type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, - MODE_SELECTIVE, - MODE_PAUSED, type SavingEntry, type DocumentID, type UXFileInfo, @@ -15,22 +13,22 @@ import { LOG_LEVEL_DEBUG, type MetaEntry, type UXDataWriteOptions, + type ObsidianLiveSyncSettings, + type LOG_LEVEL, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { type InternalFileInfo, ICHeader, ICHeaderEnd } from "@/common/types.ts"; import { readAsBlob, isDocContentSame, - sendSignal, readContent, createBlob, - fireAndForget, type CustomRegExp, - getFileRegExp, } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { compareMTime, isInternalMetadata, TARGET_IS_NEW, + cancelTask, scheduleTask, getLogLevel, autosaveCache, @@ -40,34 +38,126 @@ import { EVEN, displayRev, } from "@/common/utils.ts"; -import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts"; import { serialized, skipIfDuplicated } from "octagonal-wheels/concurrency/lock"; -import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts"; -import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts"; import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; import { QueueProcessor } from "octagonal-wheels/concurrency/processor"; -import { - hiddenFilesEventCount, - hiddenFilesProcessingCount, -} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores"; -import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; -import type { LiveSyncCore } from "@/main.ts"; import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts"; import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts"; -import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts"; import { $msg } from "@/common/translation"; +import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; +import type { KeyValueDatabase } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase"; +import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; +import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import type { HiddenFileSyncCommandView, HiddenFileSyncRepairView } from "./hiddenFileSyncViews.ts"; type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce"; -type HiddenFileInitialisationProgress = { +export type HiddenFileSyncProgress = { log(message: string): void; once(message: string): void; done(message?: string): void; }; -const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes"; -const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000; +const HIDDEN_FILE_NOTIFICATION_TASK = "notify-config-change"; + +type HiddenFileSyncSettings = Pick< + ObsidianLiveSyncSettings, + | "syncInternalFiles" + | "syncInternalFilesBeforeReplication" + | "watchInternalFileChanges" + | "useAdvancedMode" + | "syncInternalFilesInterval" + | "syncInternalFileOverwritePatterns" + | "syncInternalFilesTargetPatterns" + | "syncInternalFilesIgnorePatterns" + | "suppressNotifyHiddenFilesChange" +>; + +type HiddenFileSyncDatabase = Pick< + LiveSyncLocalDB, + | "allDocsRaw" + | "deleteDBEntry" + | "findEntries" + | "getDBEntry" + | "getDBEntryFromMeta" + | "getDBEntryMeta" + | "getRaw" + | "putDBEntry" + | "putRaw" + | "removeRevision" +> & { + readonly managers: { + readonly conflictManager: Pick; + }; +}; + +type HiddenFileSyncStorage = Pick< + StorageAccess, + | "ensureDir" + | "isExistsIncludeHidden" + | "readHiddenFileAuto" + | "removeHidden" + | "statHidden" + | "triggerHiddenFile" + | "writeHiddenFileAuto" +>; + +type HiddenFileSyncDatabaseFileAccess = Pick< + DatabaseFileAccess, + "fetchEntryFromMeta" | "fetchEntryMeta" | "getConflictedRevs" | "storeWithBaseRevision" +>; + +export type HiddenFileSyncPeriodicProcessor = { + enable(interval: number): void; + disable(): void; +}; + +export type HiddenFileSyncJsonResolution = { + keepRevision?: string; + mergedText?: string; +}; + +export type HiddenFileSyncDirectoryListing = { + files: string[]; + folders: string[]; +}; + +export type HiddenFileSyncContextDependencies = { + getSettings(): HiddenFileSyncSettings; + getLocalDatabase(): HiddenFileSyncDatabase; + getKeyValueDatabase(): KeyValueDatabase; + storageAccess: HiddenFileSyncStorage; + databaseFileAccess: HiddenFileSyncDatabaseFileAccess; + path: Pick; + log: LogFunction; + createProgress(prefix?: string, level?: LOG_LEVEL): HiddenFileSyncProgress; + createPeriodicProcessor(process: () => Promise): HiddenFileSyncPeriodicProcessor; + isReady(): boolean; + isSuspended(): boolean; + isDatabaseReady(): boolean; + isIgnoredByIgnoreFile(path: string): Promise; + getConfigDir(): string; + getRootPath(): string; + listFiles(path: string): Promise; + getFileRegExp( + key: "syncInternalFileOverwritePatterns" | "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns" + ): CustomRegExp[]; + applySettings(partial: Partial, saveImmediately?: boolean): Promise; + setSyncInternalFilesEnabled(enabled: boolean): void; + resolveJsonConflict( + path: FilePath, + docs: [LoadedEntry, LoadedEntry], + apply: (resolution: HiddenFileSyncJsonResolution) => Promise + ): Promise; + showConfigurationChangeNotice(updatedFolders: readonly string[]): void; + hideConfigurationChangeNotice(): void; + closeJsonConflictDialogs(): void; + publishActivity(eventCount: number, processingCount: number): void; + ownsLocalFile(path: FilePath): boolean; +}; function getComparingMTime( doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined, @@ -84,88 +174,100 @@ function getComparingMTime( return doc.mtime ?? 0; } -export class HiddenFileSync extends LiveSyncCommands { +export class HiddenFileSyncContext implements HiddenFileSyncCommandView, HiddenFileSyncRepairView { + private readonly dependencies: HiddenFileSyncContextDependencies; + readonly periodicInternalFileScanProcessor: HiddenFileSyncPeriodicProcessor; + private eventCount = 0; + private processingCount = 0; + private disposed = false; + + constructor(dependencies: HiddenFileSyncContextDependencies) { + this.dependencies = dependencies; + this.periodicInternalFileScanProcessor = dependencies.createPeriodicProcessor( + async () => + this.isThisModuleEnabled() && this._isDatabaseReady() && (await this.scanAllStorageChanges(false)) + ); + } + + private get settings() { + return this.dependencies.getSettings(); + } + + private get localDatabase() { + return this.dependencies.getLocalDatabase(); + } + + private get storageAccess() { + return this.dependencies.storageAccess; + } + + private get databaseFileAccess() { + return this.dependencies.databaseFileAccess; + } + + private get kvDB() { + return this.dependencies.getKeyValueDatabase(); + } + + private async path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise { + return await this.dependencies.path.path2id(filename, prefix); + } + + private getPath(entry: AnyEntry): FilePathWithPrefix { + return this.dependencies.path.getPath(entry); + } + + private _isMainReady() { + return this.dependencies.isReady(); + } + + private _isMainSuspended() { + return this.dependencies.isSuspended(); + } + + private _isDatabaseReady() { + return this.dependencies.isDatabaseReady(); + } + + private _log(message: unknown, level?: LOG_LEVEL, key?: string) { + this.dependencies.log(message, level, key); + } + + private _verbose(message: unknown, key?: string) { + this._log(message, LOG_LEVEL_VERBOSE, key); + } + + private _progress(prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) { + return this.dependencies.createProgress(prefix, level); + } + isThisModuleEnabled() { - return this.core.settings.syncInternalFiles; + return this.settings.syncInternalFiles; } - periodicInternalFileScanProcessor: PeriodicProcessor = new PeriodicProcessor( - this.core, - async () => this.isThisModuleEnabled() && this._isDatabaseReady() && (await this.scanAllStorageChanges(false)) - ); - - get kvDB() { - return this.core.kvDB; - } - getConflictedDoc(path: FilePathWithPrefix, rev: string) { - return this.core.localDatabase.managers.conflictManager.getConflictedDoc(path, rev); - } - onunload() { + dispose() { + if (this.disposed) return; + this.disposed = true; this.periodicInternalFileScanProcessor?.disable(); - } - onload() { - this.services.API.addCommand({ - id: "livesync-sync-internal", - name: "(re)initialise hidden files between storage and database", - checkCallback: (checking) => { - if (!this.isManualCommandAvailable()) return false; - if (!checking) { - void this.initialiseInternalFileSync("safe", true); - } - return true; - }, - }); - this.services.API.addCommand({ - id: "livesync-scaninternal-storage", - name: "Scan hidden file changes on the storage", - checkCallback: (checking) => { - if (!this.isManualCommandAvailable()) return false; - if (!checking) { - void this.scanAllStorageChanges(true); - } - return true; - }, - }); - this.services.API.addCommand({ - id: "livesync-scaninternal-database", - name: "Scan hidden file changes on the local database", - checkCallback: (checking) => { - if (!this.isManualCommandAvailable()) return false; - if (!checking) { - void this.scanAllDatabaseChanges(true); - } - return true; - }, - }); - this.services.API.addCommand({ - id: "livesync-internal-scan-offline-changes", - name: "Scan and apply all offline hidden-file changes", - checkCallback: (checking) => { - if (!this.isManualCommandAvailable()) return false; - if (!checking) { - void this.applyOfflineChanges(true); - } - return true; - }, - }); - eventHub.onEvent(EVENT_SETTING_SAVED, () => { - this.updateSettingCache(); - }); + this.conflictResolutionProcessor?.terminate(); + this.pendingConflictChecks.clear(); + this.queuedNotificationFiles.clear(); + this.cacheFileRegExps.clear(); + cancelTask(HIDDEN_FILE_NOTIFICATION_TASK); + this.eventCount = 0; + this.processingCount = 0; + this.dependencies.publishActivity(0, 0); + this.dependencies.closeJsonConflictDialogs(); + this.dependencies.hideConfigurationChangeNotice(); } - // We cannot initialise autosaveCache because kvDB is not ready yet - // async _everyOnInitializeDatabase(db: LiveSyncLocalDB): Promise { - // this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed"); - // this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database"); - // this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown"); - // return true; - // } - private async _everyOnDatabaseInitialized(showNotice: boolean) { + // The key-value database becomes available before this lifecycle callback. + async _everyOnDatabaseInitialized(showNotice: boolean) { this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed"); this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database"); this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown"); if (this.isThisModuleEnabled()) { - if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) { + if (this._fileInfoLastProcessed.size == 0) { this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE); await this.performStartupScan(true); } else { @@ -186,24 +288,24 @@ export class HiddenFileSync extends LiveSyncCommands { return true; } - private _everyOnloadAfterLoadSettings(): Promise { + _everyOnloadAfterLoadSettings(): Promise { this.updateSettingCache(); return Promise.resolve(true); } updateSettingCache() { - this.cacheCustomisationSyncIgnoredFiles.clear(); this.cacheFileRegExps.clear(); } isReady() { + if (this.disposed) return false; if (!this._isMainReady()) return false; if (this._isMainSuspended()) return false; if (!this.isThisModuleEnabled()) return false; return true; } - private isManualCommandAvailable() { + isManualCommandAvailable() { return this.settings.useAdvancedMode && this.isReady() && this._isDatabaseReady(); } @@ -228,17 +330,13 @@ export class HiddenFileSync extends LiveSyncCommands { _everyRealizeSettingSyncMode(): Promise { this.periodicInternalFileScanProcessor?.disable(); if (this._isMainSuspended()) return Promise.resolve(true); - if (!this.services.appLifecycle.isReady()) return Promise.resolve(true); + if (!this._isMainReady()) return Promise.resolve(true); this.periodicInternalFileScanProcessor.enable( this.isThisModuleEnabled() && this.settings.syncInternalFilesInterval ? this.settings.syncInternalFilesInterval * 1000 : 0 ); this.cacheFileRegExps.clear(); - // const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns"); - // this.ignorePatterns = ignorePatterns; - // const targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns"); - // this.targetPatterns = targetFilter; return Promise.resolve(true); } @@ -263,11 +361,6 @@ export class HiddenFileSync extends LiveSyncCommands { //system file const filename = this.getPath(doc); const unprefixedPath = stripAllPrefixes(filename); - // No need to check via vaultService - // if (!await this.services.vault.isTargetFile(unprefixedPath)) { - // this._log(`Skipped processing sync file:${unprefixedPath} (Not target)`, LOG_LEVEL_VERBOSE); - // return true; - // } if (!(await this.isTargetFile(stripAllPrefixes(unprefixedPath)))) { this._log( `Skipped processing sync file:${unprefixedPath} (Not Hidden File Sync target)`, @@ -287,7 +380,7 @@ export class HiddenFileSync extends LiveSyncCommands { } async loadFileWithInfo(path: FilePath): Promise { - const stat = await this.core.storageAccess.statHidden(path); + const stat = await this.storageAccess.statHidden(path); if (!stat) return { name: path.split("/").pop() ?? "", @@ -302,7 +395,7 @@ export class HiddenFileSync extends LiveSyncCommands { deleted: true, body: createBlob(new Uint8Array(0)), }; - const content = await this.core.storageAccess.readHiddenFileAuto(path); + const content = await this.storageAccess.readHiddenFileAuto(path); return { name: path.split("/").pop() ?? "", path, @@ -324,7 +417,7 @@ export class HiddenFileSync extends LiveSyncCommands { return `${doc.mtime}-${doc.size}-${doc._rev}-${doc._deleted || doc.deleted || false ? "-0" : "-1"}`; } async fileToStatKey(file: FilePath, stat: UXStat | null = null) { - if (!stat) stat = await this.core.storageAccess.statHidden(file); + if (!stat) stat = await this.storageAccess.statHidden(file); return this.statToKey(stat); } @@ -338,7 +431,7 @@ export class HiddenFileSync extends LiveSyncCommands { } async updateLastProcessedAsActualFile(file: FilePath, stat?: UXStat | null) { - if (!stat) stat = await this.core.storageAccess.statHidden(file); + if (!stat) stat = await this.storageAccess.statHidden(file); this._fileInfoLastProcessed.set(file, this.statToKey(stat)); } @@ -380,38 +473,38 @@ export class HiddenFileSync extends LiveSyncCommands { const dbMTime = getComparingMTime(db); const storageMTime = getComparingMTime(stat); if (dbMTime == 0 || storageMTime == 0) { - this.services.path.unmarkChanges(path); + this.dependencies.path.unmarkChanges(path); } else { - this.services.path.markChangesAreSame(path, getComparingMTime(db), getComparingMTime(stat)); + this.dependencies.path.markChangesAreSame(path, getComparingMTime(db), getComparingMTime(stat)); } } updateLastProcessedDeletion(path: FilePath, db: MetaEntry | LoadedEntry | false) { - this.services.path.unmarkChanges(path); + this.dependencies.path.unmarkChanges(path); if (db) this.updateLastProcessedDatabase(path, db); this.updateLastProcessedFile(path, this.statToKey(null)); } async ensureDir(path: FilePath) { - const isExists = await this.core.storageAccess.isExistsIncludeHidden(path); + const isExists = await this.storageAccess.isExistsIncludeHidden(path); if (!isExists) { - await this.core.storageAccess.ensureDir(path); + await this.storageAccess.ensureDir(path); } } async writeFile(path: FilePath, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise { - await this.core.storageAccess.writeHiddenFileAuto(path, data, opt); - const stat = await this.core.storageAccess.statHidden(path); + await this.storageAccess.writeHiddenFileAuto(path, data, opt); + const stat = await this.storageAccess.statHidden(path); // this.updateLastProcessedFile(path, this.statToKey(stat)); return stat; } async __removeFile(path: FilePath): Promise<"OK" | "ALREADY" | false> { try { - if (!(await this.core.storageAccess.isExistsIncludeHidden(path))) { + if (!(await this.storageAccess.isExistsIncludeHidden(path))) { // Already deleted // this.updateLastProcessedFile(path, this.statToKey(null)); return "ALREADY"; } - if (await this.core.storageAccess.removeHidden(path)) { + if (await this.storageAccess.removeHidden(path)) { // this.updateLastProcessedFile(path, this.statToKey(null)); return "OK"; } @@ -423,8 +516,7 @@ export class HiddenFileSync extends LiveSyncCommands { } async triggerEvent(path: FilePath) { try { - // await this.app.vault.adapter.reconcileInternalFile(filename); - await this.core.storageAccess.triggerHiddenFile(path); + await this.storageAccess.triggerHiddenFile(path); } catch (ex) { this._log("Failed to call internal API(reconcileInternalFile)", LOG_LEVEL_VERBOSE); this._log(ex, LOG_LEVEL_VERBOSE); @@ -473,23 +565,34 @@ export class HiddenFileSync extends LiveSyncCommands { semaphore = Semaphore(10); async serializedForEvent(file: FilePath, fn: () => Promise) { - hiddenFilesEventCount.value++; + this.eventCount++; + this.publishActivity(); const rel = await this.semaphore.acquire(); try { return await serialized(`hidden-file-event:${file}`, async () => { - hiddenFilesProcessingCount.value++; + this.processingCount++; + this.publishActivity(); try { return await fn(); } finally { - hiddenFilesProcessingCount.value--; + this.processingCount = Math.max(0, this.processingCount - 1); + this.publishActivity(); } }); } finally { rel(); - hiddenFilesEventCount.value--; + this.eventCount = Math.max(0, this.eventCount - 1); + this.publishActivity(); } } + private publishActivity() { + this.dependencies.publishActivity( + this.disposed ? 0 : this.eventCount, + this.disposed ? 0 : this.processingCount + ); + } + async useStorageFiles(files: FilePath[], showNotice = false, onlyNew = false) { return await this.trackScannedStorageChanges(files, showNotice, onlyNew, true); } @@ -534,9 +637,7 @@ export class HiddenFileSync extends LiveSyncCommands { `Known/Exist ${knownNames.length}/${existNames.length}, Totally ${files.size} files.`, LOG_LEVEL_VERBOSE ); - const taskNameAndMeta = [...files].map( - async (e) => [e, await this.core.storageAccess.statHidden(e)] as const - ); + const taskNameAndMeta = [...files].map(async (e) => [e, await this.storageAccess.statHidden(e)] as const); const nameAndMeta = await Promise.all(taskNameAndMeta); const processFiles = nameAndMeta .filter(([path, stat]) => { @@ -577,7 +678,7 @@ Offline Changed files: ${processFiles.length}`; } try { return await this.serializedForEvent(path, async () => { - let stat = await this.core.storageAccess.statHidden(path); + let stat = await this.storageAccess.statHidden(path); // sometimes folder is coming. if (stat != null && stat.type != "file") { return false; @@ -657,6 +758,7 @@ Offline Changed files: ${processFiles.length}`; pendingConflictChecks = new Set(); queueConflictCheck(path: FilePathWithPrefix) { + if (this.disposed) return; if (this.pendingConflictChecks.has(path)) return; this.pendingConflictChecks.add(path); this.conflictResolutionProcessor.enqueue(path); @@ -768,7 +870,7 @@ Offline Changed files: ${processFiles.length}`; this._log(`Object merge is not applicable.`, LOG_LEVEL_VERBOSE); } // const pat = this.settings.syncInternalFileOverwritePatterns; - const regExp = getFileRegExp(this.settings, "syncInternalFileOverwritePatterns"); + const regExp = this.dependencies.getFileRegExp("syncInternalFileOverwritePatterns"); if (regExp.some((r) => r.test(stripAllPrefixes(path)))) { this._log(`Overwrite rule applied for conflicted hidden file: ${path}`, LOG_LEVEL_INFO); await this.resolveByNewerEntry(id, path, doc, revA, revB); @@ -829,71 +931,58 @@ Offline Changed files: ${processFiles.length}`; } ); - showJSONMergeDialogAndMerge(docA: LoadedEntry, docB: LoadedEntry): Promise { - return new Promise((res) => { - this._log("Opening data-merging dialog", LOG_LEVEL_VERBOSE); - const docs = [docA, docB]; - const strippedPath = stripAllPrefixes(docA.path); - const storageFilePath = strippedPath; - const storeFilePath = strippedPath; - const displayFilename = `${storeFilePath}`; - // const path = this.prefixedConfigDir2configDir(stripAllPrefixes(docA.path)) || docA.path; - // Cancel only when replacing an existing dialogue for the same path, not on every queue pass. - sendSignal(`cancel-internal-conflict:${docA.path}`); - const modal = new JsonResolveModal(this.app, storageFilePath, [docA, docB], async (keep, result) => { - // modal.close(); + async showJSONMergeDialogAndMerge(docA: LoadedEntry, docB: LoadedEntry): Promise { + this._log("Opening data-merging dialog", LOG_LEVEL_VERBOSE); + const docs: [LoadedEntry, LoadedEntry] = [docA, docB]; + const storageFilePath = stripAllPrefixes(docA.path); + const displayFilename = `${storageFilePath}`; + return await this.dependencies.resolveJsonConflict( + storageFilePath, + docs, + async ({ keepRevision: keep, mergedText: result }) => { try { - // const filename = storeFilePath; let needFlush = false; if (!result && !keep) { this._log(`Skipped merging: ${displayFilename}`); - res(false); - return; + return false; } - //Delete old revisions - if (result || keep) { - for (const doc of docs) { - if (doc._rev != keep) { - if (await this.localDatabase.deleteDBEntry(this.getPath(doc), { rev: doc._rev })) { - this._log(`Conflicted revision has been deleted: ${displayFilename}`); - needFlush = true; - } + for (const doc of docs) { + if (doc._rev != keep) { + if (await this.localDatabase.deleteDBEntry(this.getPath(doc), { rev: doc._rev })) { + this._log(`Conflicted revision has been deleted: ${displayFilename}`); + needFlush = true; } } } if (!keep && result) { - const isExists = await this.core.storageAccess.isExistsIncludeHidden(storageFilePath); - if (!isExists) { - await this.core.storageAccess.ensureDir(storageFilePath); - } + await this.ensureDir(storageFilePath); const stat = await this.writeFile(storageFilePath, result); if (!stat) { throw new Error("Stat failed"); } const mtime = getComparingMTime(stat); await this.storeInternalFileToDatabase( - { path: storageFilePath, mtime, ctime: stat?.ctime ?? mtime, size: stat?.size ?? 0 }, + { path: storageFilePath, mtime, ctime: stat.ctime ?? mtime, size: stat.size ?? 0 }, true ); await this.triggerEvent(storageFilePath); this._log(`STORAGE <-- DB:${displayFilename}: written (hidden,merged)`); } if (needFlush) { - if (await this.extractInternalFileFromDatabase(storeFilePath, false)) { + if (await this.extractInternalFileFromDatabase(storageFilePath, false)) { this._log(`STORAGE --> DB:${displayFilename}: extracted (hidden,merged)`); } else { this._log(`STORAGE --> DB:${displayFilename}: extracted (hidden,merged) Failed`); } } - res(true); + return true; } catch (ex) { this._log("Could not merge conflicted json"); this._log(ex, LOG_LEVEL_VERBOSE); - res(false); + return false; } - }); - modal.open(); - }); + } + ); } // <-- Conflict processing @@ -941,7 +1030,7 @@ Offline Changed files: ${processFiles.length}`; * @returns An object containing the ignore and target filters. */ parseRegExpSettings() { - const regExpKey = `${this.core.settings.syncInternalFilesTargetPatterns}||${this.core.settings.syncInternalFilesIgnorePatterns}`; + const regExpKey = `${this.settings.syncInternalFilesTargetPatterns}||${this.settings.syncInternalFilesIgnorePatterns}`; let ignoreFilter: CustomRegExp[]; let targetFilter: CustomRegExp[]; if (this.cacheFileRegExps.has(regExpKey)) { @@ -949,8 +1038,8 @@ Offline Changed files: ${processFiles.length}`; ignoreFilter = cached[1]; targetFilter = cached[0]; } else { - ignoreFilter = getFileRegExp(this.core.settings, "syncInternalFilesIgnorePatterns"); - targetFilter = getFileRegExp(this.core.settings, "syncInternalFilesTargetPatterns"); + ignoreFilter = this.dependencies.getFileRegExp("syncInternalFilesIgnorePatterns"); + targetFilter = this.dependencies.getFileRegExp("syncInternalFilesTargetPatterns"); this.cacheFileRegExps.clear(); this.cacheFileRegExps.set(regExpKey, [targetFilter, ignoreFilter]); } @@ -982,61 +1071,28 @@ Offline Changed files: ${processFiles.length}`; return true; } - cacheCustomisationSyncIgnoredFiles = new Map(); - /** - * Gets the list of files ignored for customization synchronization. - * @returns An array of ignored file paths (lowercase). - */ - getCustomisationSynchronizationIgnoredFiles(): string[] { - const configDir = this.services.API.getSystemConfigDir(); - const key = - JSON.stringify(this.settings.pluginSyncExtendedSetting) + `||${this.settings.usePluginSync}||${configDir}`; - if (this.cacheCustomisationSyncIgnoredFiles.has(key)) { - return this.cacheCustomisationSyncIgnoredFiles.get(key)!; - } - this.cacheCustomisationSyncIgnoredFiles.clear(); - const synchronisedInConfigSync = !this.settings.usePluginSync - ? [] - : Object.values(this.settings.pluginSyncExtendedSetting) - .filter((e) => e.mode == MODE_SELECTIVE || e.mode == MODE_PAUSED) - .map((e) => e.files) - .flat() - .map((e) => `${configDir}/${e}`.toLowerCase()); - this.cacheCustomisationSyncIgnoredFiles.set(key, synchronisedInConfigSync); - return synchronisedInConfigSync; - } - /** - * Checks if the given path is not ignored by customization synchronization. - * @param path The file path to check. - * @returns True if the path is not ignored; otherwise, false. - */ - isNotIgnoredByCustomisationSync(path: string): boolean { - const ignoredFiles = this.getCustomisationSynchronizationIgnoredFiles(); - const result = !ignoredFiles.some((e) => path.startsWith(e)); - // console.warn(`Assertion: isNotIgnoredByCustomisationSync(${path}) = ${result}`); - return result; - } - isHiddenFileSyncHandlingPath(path: FilePath): boolean { const result = path.startsWith(".") && !path.startsWith(".trash"); // console.warn(`Assertion: isHiddenFileSyncHandlingPath(${path}) = ${result}`); return result; } - async isTargetFile(path: FilePath): Promise { - const result = - this.isTargetFileInPatterns(path) && - this.isNotIgnoredByCustomisationSync(path) && - this.isHiddenFileSyncHandlingPath(path); + async isTargetFileEligible(path: FilePath): Promise { + const result = this.isTargetFileInPatterns(path) && this.isHiddenFileSyncHandlingPath(path); // console.warn(`Assertion: isTargetFile(${path}) : ${result ? "✔️" : "❌"}`); if (!result) { return false; } - const resultByFile = await this.services.vault.isIgnoredByIgnoreFile(path); + const resultByFile = await this.dependencies.isIgnoredByIgnoreFile(path); // console.warn(`${path} -> isIgnoredByIgnoreFile: ${resultByFile ? "❌" : "✔️"}`); return !resultByFile; } + async isTargetFile(path: FilePath): Promise { + if (this.dependencies?.ownsLocalFile(path) === false) return false; + return await this.isTargetFileEligible(path); + } + async trackScannedDatabaseChange( processFiles: MetaEntry[], showNotice: boolean = false, @@ -1105,7 +1161,7 @@ Common untracked files: ${bothUntracked.length}`; notifyProgress(); const rel = await semaphores.acquire(); try { - const fileStat = await this.core.storageAccess.statHidden(file); + const fileStat = await this.storageAccess.statHidden(file); if (fileStat == null) { // This should not be happened. But, if it happens, we should skip this. this._log(`Unexpected error: Failed to stat file during applyOfflineChange :${file}`); @@ -1242,81 +1298,20 @@ Offline Changed files: ${files.length}`; notifyConfigChange() { const updatedFolders = [...this.queuedNotificationFiles]; this.queuedNotificationFiles.clear(); - const noticeGroups = this.services.context.noticeGroups; - let hasNoticeItems = false; - try { - const pluginManager = getObsidianCommunityPluginManager(this.app); - const enabledPluginManifests = pluginManager.manifests.filter((manifest) => - pluginManager.enabledPlugins.has(manifest.id) - ); - const modifiedManifests = enabledPluginManifests.filter((e) => updatedFolders.indexOf(e?.dir ?? "") >= 0); - for (const manifest of modifiedManifests) { - // If notified about plug-ins, reloading Obsidian may not be necessary. - const updatePluginId = manifest.id; - const updatePluginName = manifest.name; - const itemKey = `plugin:${updatePluginId}`; - noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, itemKey, { - message: `Files in ${updatePluginName} were updated.`, - action: { - label: `Reload ${updatePluginName}`, - onSelect: () => { - fireAndForget(async () => { - this._log( - `Unloading plugin: ${updatePluginName}`, - LOG_LEVEL_NOTICE, - "plugin-reload-" + updatePluginId - ); - await pluginManager.unloadPlugin(updatePluginId); - await pluginManager.loadPlugin(updatePluginId); - this._log( - `Plugin reloaded: ${updatePluginName}`, - LOG_LEVEL_NOTICE, - "plugin-reload-" + updatePluginId - ); - noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, itemKey); - }); - }, - }, - }); - hasNoticeItems = true; - } - } catch (ex) { - this._log("Error on checking plugin status."); - this._log(ex, LOG_LEVEL_VERBOSE); - } - - // If something changes left, notify for reloading Obsidian. - if (updatedFolders.indexOf(this.services.API.getSystemConfigDir()) >= 0) { - if (!this.services.appLifecycle.isReloadingScheduled()) { - noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, "restart", { - message: "Other Obsidian settings files were updated.", - action: { - label: "Schedule an Obsidian restart", - onSelect: () => { - this.services.appLifecycle.scheduleRestart(); - noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, "restart"); - }, - }, - }); - hasNoticeItems = true; - } else { - noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, "restart"); - } - } - if (hasNoticeItems) { - noticeGroups.finish(HIDDEN_FILE_NOTICE_GROUP, { durationMs: HIDDEN_FILE_NOTICE_DURATION_MS }); - } + if (this.disposed) return; + this.dependencies.showConfigurationChangeNotice(updatedFolders); } queueNotification(key: FilePath) { + if (this.disposed) return; if (this.settings.suppressNotifyHiddenFilesChange) { return; } - const configDir = this.services.API.getSystemConfigDir(); + const configDir = this.dependencies.getConfigDir(); if (!key.startsWith(configDir)) return; const dirName = key.split("/").slice(0, -1).join("/"); this.queuedNotificationFiles.add(dirName); - scheduleTask("notify-config-change", 1000, () => { + scheduleTask(HIDDEN_FILE_NOTIFICATION_TASK, 1000, () => { this.notifyConfigChange(); }); } @@ -1350,7 +1345,7 @@ Offline Changed files: ${files.length}`; const eachProgress = onlyInNTimes(100, (progress) => p.log(`Checking ${progress}/${allFileNames.size}`)); for (const file of allFileNames) { eachProgress(); - const storageMTime = await this.core.storageAccess.statHidden(file); + const storageMTime = await this.storageAccess.statHidden(file); const mtimeStorage = getComparingMTime(storageMTime); const dbEntry = allDatabaseMap.get(file)!; const mtimeDB = getComparingMTime(dbEntry); @@ -1441,7 +1436,7 @@ Offline Changed files: ${files.length}`; showMessage: boolean, // filesAll: InternalFileInfo[] | false = false, targetFilesSrc: string[] | false = false, - initialisationProgress?: HiddenFileInitialisationProgress + initialisationProgress?: HiddenFileSyncProgress ) { const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel); @@ -1536,9 +1531,9 @@ Offline Changed files: ${files.length}`; revision: string ): Promise { const [selected, current, conflicts] = await Promise.all([ - this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, revision, true), - this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, undefined, true), - this.core.databaseFileAccess.getConflictedRevs(prefixedFileName), + this.databaseFileAccess.fetchEntryMeta(prefixedFileName, revision, true), + this.databaseFileAccess.fetchEntryMeta(prefixedFileName, undefined, true), + this.databaseFileAccess.getConflictedRevs(prefixedFileName), ]); const liveRevisions = new Set([...(current && current._rev ? [current._rev] : []), ...conflicts]); if (!selected || selected._rev !== revision || !liveRevisions.has(revision)) { @@ -1554,7 +1549,7 @@ Offline Changed files: ${files.length}`; async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) { const storeFilePath = stripAllPrefixes(file.path); const storageFilePath = file.path; - if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) { + if (await this.dependencies.isIgnoredByIgnoreFile(storageFilePath)) { return undefined; } const prefixedFileName = addPrefix(storeFilePath, ICHeader); @@ -1609,7 +1604,7 @@ Offline Changed files: ${files.length}`; ): Promise { const storeFilePath = stripAllPrefixes(file.path); const storageFilePath = file.path; - if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) { + if (await this.dependencies.isIgnoredByIgnoreFile(storageFilePath)) { return false; } const prefixedFileName = addPrefix(storeFilePath, ICHeader); @@ -1625,7 +1620,7 @@ Offline Changed files: ${files.length}`; throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`); } if (!baseData.deleted && !baseData._deleted) { - const loadedBase = await this.core.databaseFileAccess.fetchEntryFromMeta(baseData, true, true); + const loadedBase = await this.databaseFileAccess.fetchEntryFromMeta(baseData, true, true); if (loadedBase && (await isDocContentSame(readAsBlob(loadedBase), fileInfo.body))) { this.updateLastProcessed(storeFilePath, baseData, fileInfo.stat); return true; @@ -1639,7 +1634,7 @@ Offline Changed files: ${files.length}`; return false; } - const storedRevision = await this.core.databaseFileAccess.storeWithBaseRevision( + const storedRevision = await this.databaseFileAccess.storeWithBaseRevision( { ...fileInfo, path: storeFilePath, @@ -1681,7 +1676,7 @@ Offline Changed files: ${files.length}`; const displayFileName = filenameSrc; const prefixedFileName = addPrefix(storeFilePath, ICHeader); const mtime = new Date().getTime(); - if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) { + if (await this.dependencies.isIgnoredByIgnoreFile(storageFilePath)) { return undefined; } return await serialized("file-" + prefixedFileName, async () => { @@ -1742,7 +1737,7 @@ Offline Changed files: ${files.length}`; requiredLiveRevision?: string ) { const prefixedFileName = addPrefix(storageFilePath, ICHeader); - if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) { + if (await this.dependencies.isIgnoredByIgnoreFile(storageFilePath)) { return undefined; } return await serialized("file-" + prefixedFileName, async () => { @@ -1774,7 +1769,7 @@ Offline Changed files: ${files.length}`; if (onlyNew) { // Check the file is new or not. const dbMTime = getComparingMTime(metaOnDB, includeDeletion); // metaOnDB.mtime; - const storageStat = await this.core.storageAccess.statHidden(storageFilePath); + const storageStat = await this.storageAccess.statHidden(storageFilePath); const storageMTimeActual = storageStat?.mtime ?? 0; const storageMTime = storageMTimeActual == 0 ? this.getLastProcessedFileMTime(storageFilePath) : storageMTimeActual; @@ -1838,7 +1833,7 @@ Offline Changed files: ${files.length}`; async __checkIsNeedToWriteFile(storageFilePath: FilePath, content: string | ArrayBuffer): Promise { try { - const storageContent = await this.core.storageAccess.readHiddenFileAuto(storageFilePath); + const storageContent = await this.storageAccess.readHiddenFileAuto(storageFilePath); const needWrite = !(await isDocContentSame(storageContent, content)); return needWrite; } catch (ex) { @@ -1850,7 +1845,7 @@ Offline Changed files: ${files.length}`; async __writeFile(storageFilePath: FilePath, fileOnDB: LoadedEntry, force: boolean): Promise { try { - const statBefore = await this.core.storageAccess.statHidden(storageFilePath); + const statBefore = await this.storageAccess.statHidden(storageFilePath); const isExist = statBefore != null; const writeContent = readContent(fileOnDB); await this.ensureDir(storageFilePath); @@ -1904,40 +1899,37 @@ Offline Changed files: ${files.length}`; // <-- Database To Storage Functions - private _allSuspendExtraSync(): Promise { - if (this.core.settings.syncInternalFiles) { + _allSuspendExtraSync(): Promise { + if (this.settings.syncInternalFiles) { this._log( $msg( "Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them." ), LOG_LEVEL_NOTICE ); - this.core.settings.syncInternalFiles = false; + this.dependencies.setSyncInternalFilesEnabled(false); } return Promise.resolve(true); } // --> Configuration handling - private async _allConfigureOptionalSyncFeature(mode: OptionalSyncFeatureMode) { + async _allConfigureOptionalSyncFeature(mode: OptionalSyncFeatureMode) { await this.configureHiddenFileSync(mode); return true; } async configureHiddenFileSync(mode: OptionalSyncFeatureMode) { - let initialisationProgress: HiddenFileInitialisationProgress | undefined; + let initialisationProgress: HiddenFileSyncProgress | undefined; let result: ConfigureHiddenFileSyncResult; try { result = await configureHiddenFileSyncMode(mode, { disable: async () => { - // await this.core.$allSuspendExtraSync(); - await this.core.services.setting.applyPartial( + await this.dependencies.applySettings( { syncInternalFiles: false, }, true ); - // this.core.settings.syncInternalFiles = false; - // await this.core.saveSettings(); }, enable: async () => { // Open the one user-visible progress Notice before saving @@ -1945,7 +1937,7 @@ Offline Changed files: ${files.length}`; // before the initial file enumeration begins. initialisationProgress = this._progress("[⚙ Initialise]\n", LOG_LEVEL_NOTICE); initialisationProgress.log("Preparing Hidden File Sync..."); - await this.core.services.setting.applyPartial( + await this.dependencies.applySettings( { useAdvancedMode: true, syncInternalFiles: true, @@ -1965,18 +1957,13 @@ Offline Changed files: ${files.length}`; if (result == "ignored" || result == "disabled") { return; } - // this.plugin.settings.useAdvancedMode = true; - // this.plugin.settings.syncInternalFiles = true; - - // await this.plugin.saveSettings(); this._log("Hidden File Sync initialisation completed.", LOG_LEVEL_INFO); } // <-- Configuration handling // --> Local Storage SubFunctions async scanInternalFileNames() { - const root = this.app.vault.getRoot(); - const findRoot = root.path; + const findRoot = this.dependencies.getRootPath(); const filenames = await this.getFiles(findRoot, (path) => this.isTargetFile(path)); @@ -1988,13 +1975,13 @@ Offline Changed files: ${files.length}`; const files = fileNames.map(async (e) => { return { path: e, - stat: await this.core.storageAccess.statHidden(e), // this.plugin.vaultAccess.adapterStat(e) + stat: await this.storageAccess.statHidden(e), }; }); const result: InternalFileInfo[] = []; for (const f of files) { const w = await f; - if (await this.services.vault.isIgnoredByIgnoreFile(w.path)) { + if (await this.dependencies.isIgnoredByIgnoreFile(w.path)) { continue; } const mtime = w.stat?.mtime ?? 0; @@ -2011,9 +1998,9 @@ Offline Changed files: ${files.length}`; } async getFiles(path: string, checkFunction: (path: FilePath) => Promise | boolean) { - let w: ListedFiles; + let w: HiddenFileSyncDirectoryListing; try { - w = await this.app.vault.adapter.list(path); + w = await this.dependencies.listFiles(path); } catch (ex) { this._log(`Could not traverse(HiddenSync):${path}`, LOG_LEVEL_INFO); this._log(ex, LOG_LEVEL_VERBOSE); @@ -2034,66 +2021,5 @@ Offline Changed files: ${files.length}`; } return files; } - /* - async getFiles_(path: string, ignoreList: string[], filter?: CustomRegExp[], ignoreFilter?: CustomRegExp[]) { - let w: ListedFiles; - try { - w = await this.app.vault.adapter.list(path); - } catch (ex) { - this._log(`Could not traverse(HiddenSync):${path}`, LOG_LEVEL_INFO); - this._log(ex, LOG_LEVEL_VERBOSE); - return []; - } - let files = [] as string[]; - for (const file of w.files) { - if (ignoreList && ignoreList.length > 0) { - if (ignoreList.some((e) => file.endsWith(e))) continue; - } - if (filter && filter.length > 0) { - if (!filter.some((e) => e.test(file))) { - continue; - } - } - if (ignoreFilter && ignoreFilter.some((ee) => ee.test(file))) { - continue; - } - if (await this.services.vault.isIgnoredByIgnoreFile(file)) continue; - files.push(file); - } - L1: for (const v of w.folders) { - for (const ignore of ignoreList) { - if (v.endsWith(ignore)) { - continue L1; - } - } - if (ignoreFilter && ignoreFilter.some((e) => e.test(v))) { - continue L1; - } - if (await this.services.vault.isIgnoredByIgnoreFile(v)) { - continue L1; - } - files = files.concat(await this.getFiles_(v, ignoreList, filter, ignoreFilter)); - } - return files; - } - */ // <-- Local Storage SubFunctions - - override onBindFunction(core: LiveSyncCore, services: typeof core.services) { - // No longer needed on initialisation - // services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this)); - services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this)); - services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this)); - services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this)); - services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this)); - services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this)); - services.appLifecycle.onResuming.addHandler(this._everyOnResumeProcess.bind(this)); - services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this)); - services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this)); - services.setting.suspendExtraSync.addHandler(this._allSuspendExtraSync.bind(this)); - services.setting.enableOptionalFeature.addHandler(this._allConfigureOptionalSyncFeature.bind(this)); - services.vault.isTargetFileInExtra.addHandler((file) => - this.isTargetFile((typeof file === "string" ? file : stripAllPrefixes(file.path)) as FilePath) - ); - } } diff --git a/src/features/HiddenFileSync/hiddenFileSyncContext.unit.spec.ts b/src/features/HiddenFileSync/hiddenFileSyncContext.unit.spec.ts new file mode 100644 index 00000000..99807b43 --- /dev/null +++ b/src/features/HiddenFileSync/hiddenFileSyncContext.unit.spec.ts @@ -0,0 +1,350 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type DocumentID, + LOG_LEVEL_NOTICE, + type FilePath, + type FilePathWithPrefix, + type MetaEntry, + type UXFileInfo, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; + +vi.mock("@/deps.ts", () => ({})); +vi.mock("./configureHiddenFileSyncMode.ts", () => ({ + configureHiddenFileSyncMode: vi.fn(), +})); + +import { HiddenFileSyncContext } from "./hiddenFileSyncContext.ts"; +import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts"; + +function createHiddenRevisionOperation() { + const path = ".obsidian/plugins/example/data.json" as FilePath; + const file = { + path, + name: "data.json", + isInternal: true, + body: new Blob(['{"value":"vault"}']), + stat: { + ctime: 1, + mtime: 2, + size: 17, + type: "file", + }, + } as UXFileInfo; + const selected = { + _id: "i:example" as DocumentID, + _rev: "2-selected", + path: `i:${path}` as FilePathWithPrefix, + ctime: 1, + mtime: 2, + size: 17, + type: "plain", + datatype: "plain", + children: [], + eden: {}, + deleted: false, + } as MetaEntry; + const winner = { + ...selected, + _rev: "3-winner", + } as MetaEntry; + const databaseFileAccess = { + fetchEntryMeta: vi.fn(async (_path: unknown, revision?: string) => + revision === selected._rev ? selected : winner + ), + getConflictedRevs: vi.fn(async () => [selected._rev]), + fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: '{"value":"database"}' })), + storeWithBaseRevision: vi.fn(async () => "3-vault-child"), + }; + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + Object.assign(hiddenFileSync, { + dependencies: { + databaseFileAccess, + isIgnoredByIgnoreFile: vi.fn(async () => false), + }, + loadFileWithInfo: vi.fn(async () => file), + updateLastProcessed: vi.fn(), + _log: vi.fn(), + }); + return { + hiddenFileSync, + path, + file, + selected, + winner, + databaseFileAccess, + }; +} + +describe("HiddenFileSyncContext configuration-change notices", () => { + it("releases processors, transient queues, the pattern cache, activity, and the host Notice effect", () => { + const periodicInternalFileScanProcessor = { disable: vi.fn() }; + const conflictResolutionProcessor = { terminate: vi.fn() }; + const pendingConflictChecks = new Set(["i:.obsidian/example.json"]); + const queuedNotificationFiles = new Set([".obsidian/plugins/example"]); + const cacheFileRegExps = new Map([["patterns", []]]); + const publishActivity = vi.fn(); + const hideConfigurationChangeNotice = vi.fn(); + const closeJsonConflictDialogs = vi.fn(); + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + Object.assign(hiddenFileSync, { + dependencies: { publishActivity, hideConfigurationChangeNotice, closeJsonConflictDialogs }, + periodicInternalFileScanProcessor, + conflictResolutionProcessor, + pendingConflictChecks, + queuedNotificationFiles, + cacheFileRegExps, + eventCount: 4, + processingCount: 2, + }); + + hiddenFileSync.dispose(); + hiddenFileSync.dispose(); + + expect(periodicInternalFileScanProcessor.disable).toHaveBeenCalledOnce(); + expect(conflictResolutionProcessor.terminate).toHaveBeenCalledOnce(); + expect(pendingConflictChecks.size).toBe(0); + expect(queuedNotificationFiles.size).toBe(0); + expect(cacheFileRegExps.size).toBe(0); + expect(publishActivity).toHaveBeenCalledWith(0, 0); + expect(closeJsonConflictDialogs).toHaveBeenCalledOnce(); + expect(hideConfigurationChangeNotice).toHaveBeenCalledOnce(); + }); + + it("does not report Hidden File Sync as ready before the main runtime is ready", () => { + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + Object.assign(hiddenFileSync, { + dependencies: { + getSettings: () => ({ + syncInternalFiles: true, + }), + }, + _isMainReady: vi.fn(() => false), + _isMainSuspended: vi.fn(() => false), + }); + + expect(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"]), + }); + + hiddenFileSync.notifyConfigChange(); + + expect(showConfigurationChangeNotice).toHaveBeenCalledWith([ + ".obsidian/plugins/alpha", + ".obsidian/plugins/beta", + ".obsidian", + ]); + expect(hiddenFileSync.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(), + once: vi.fn(), + done: vi.fn(), + }; + const rebuildMerging = vi.fn(async () => []); + const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined); + const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined); + const scanAllStorageChanges = vi.fn(async () => undefined); + const scanAllDatabaseChanges = vi.fn(async () => undefined); + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + Object.assign(hiddenFileSync, { + _progress: vi.fn(() => progress), + rebuildMerging, + adoptCurrentStorageFilesAsProcessed, + adoptCurrentDatabaseFilesAsProcessed, + scanAllStorageChanges, + scanAllDatabaseChanges, + }); + + await hiddenFileSync.initialiseInternalFileSync("safe", true); + + expect(rebuildMerging).toHaveBeenCalledWith(false, false); + expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false); + expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false); + expect(progress.done).toHaveBeenCalledOnce(); + }); + + it("retirement guard: does not restore separate gathering and restart Notices", async () => { + vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => { + await handlers.enable(); + await handlers.initialise("safe"); + return "enabled"; + }); + const events: string[] = []; + const progress = { + log: vi.fn((message: string) => { + events.push(`progress:${message}`); + }), + once: vi.fn(), + done: vi.fn(), + }; + const createProgress = vi.fn(() => progress); + const applyPartial = vi.fn(async () => { + events.push("apply-settings"); + }); + const initialiseInternalFileSync = vi.fn(async () => undefined); + const log = vi.fn(); + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + Object.assign(hiddenFileSync, { + dependencies: { applySettings: applyPartial }, + initialiseInternalFileSync, + _progress: createProgress, + _log: log, + }); + + await hiddenFileSync.configureHiddenFileSync("MERGE"); + + expect(createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE); + expect(events[0]).toBe("progress:Preparing Hidden File Sync..."); + expect(initialiseInternalFileSync).toHaveBeenCalledWith("safe", true, false, progress); + expect(log).not.toHaveBeenCalledWith("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE); + expect(log).not.toHaveBeenCalledWith("Done! Restarting the app is strongly recommended!", LOG_LEVEL_NOTICE); + expect(log).toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number)); + }); + + it("closes the preparation Notice when enabling Hidden File Sync fails", async () => { + vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => { + await handlers.enable(); + return "enabled"; + }); + const error = new Error("setting persistence failed"); + const progress = { + log: vi.fn(), + once: vi.fn(), + done: vi.fn(), + }; + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + Object.assign(hiddenFileSync, { + dependencies: { + applySettings: vi.fn(async () => { + throw error; + }), + }, + _progress: vi.fn(() => progress), + _log: vi.fn(), + }); + + await expect(hiddenFileSync.configureHiddenFileSync("MERGE")).rejects.toBe(error); + + expect(progress.done).toHaveBeenCalledWith("Failed"); + }); +}); + +describe("HiddenFileSyncContext reconciliation settlement", () => { + it("compatibility: consumes a selected database file even when reading its Metadata fails", async () => { + const error = new Error("metadata unavailable"); + const getDBEntryMeta = vi.fn(async () => { + throw error; + }); + const log = vi.fn(); + const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext; + Object.assign(hiddenFileSync, { + dependencies: { + getLocalDatabase: () => ({ getDBEntryMeta }), + log, + }, + serializedForEvent: vi.fn(async (_path: FilePath, operation: () => Promise) => await operation()), + }); + + await expect( + hiddenFileSync.trackDatabaseFileModification(".obsidian/app.json" as FilePath, "[Replication]") + ).resolves.toBe(true); + + expect(log).toHaveBeenCalledWith("[Replication] Failed to process hidden file", undefined, undefined); + expect(log).toHaveBeenCalledWith(error, expect.any(Number), undefined); + }); +}); + +describe("HiddenFileSyncContext exact revision repair operations", () => { + it("stores the current hidden Vault file as a child of the selected live revision", async () => { + const { hiddenFileSync, file, selected, databaseFileAccess } = createHiddenRevisionOperation(); + + await expect(hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)).resolves.toBe( + true + ); + + expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith( + expect.objectContaining({ + path: file.path, + body: file.body, + isInternal: true, + }), + selected._rev, + true + ); + expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith( + file.path, + expect.objectContaining({ _rev: "3-vault-child" }), + file.stat + ); + }); + + it("refuses to extend a hidden-file revision which is no longer live", async () => { + const { hiddenFileSync, file, selected, databaseFileAccess } = createHiddenRevisionOperation(); + databaseFileAccess.getConflictedRevs.mockResolvedValue([]); + + await expect(hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)).resolves.toBe( + false + ); + + expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); + expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled(); + }); + + it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => { + const { hiddenFileSync, file, selected, databaseFileAccess } = createHiddenRevisionOperation(); + + await expect( + hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!, false) + ).resolves.toBe(false); + + expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); + expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled(); + }); + + it("marks a matching hidden-file revision without creating a child", async () => { + const { hiddenFileSync, file, selected, databaseFileAccess } = createHiddenRevisionOperation(); + databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({ + ...selected, + data: '{"value":"vault"}', + }); + + await expect( + hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!, false) + ).resolves.toBe(true); + + expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); + expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(file.path, selected, file.stat); + }); + + it("applies the selected live hidden-file revision through the existing extraction path", async () => { + const { hiddenFileSync, path, selected } = createHiddenRevisionOperation(); + const extract = vi.fn(async () => true); + hiddenFileSync.extractInternalFileFromDatabase = extract; + + await expect(hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)).resolves.toBe( + true + ); + + expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev); + }); + + it("does not apply a hidden-file revision which ceased to be live", async () => { + const { hiddenFileSync, path, selected, databaseFileAccess } = createHiddenRevisionOperation(); + databaseFileAccess.getConflictedRevs.mockResolvedValue([]); + + await expect(hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)).resolves.toBe( + false + ); + + expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/HiddenFileSync/hiddenFileSyncViews.ts b/src/features/HiddenFileSync/hiddenFileSyncViews.ts new file mode 100644 index 00000000..1fa176f5 --- /dev/null +++ b/src/features/HiddenFileSync/hiddenFileSyncViews.ts @@ -0,0 +1,38 @@ +import type { InternalFileInfo } from "@/common/types.ts"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +export type HiddenFileSyncInitialisationDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce"; + +/** Initialisation operation needed by the Customisation Sync dialogue. */ +export interface HiddenFileSyncInitialisationView { + initialiseInternalFileSync( + direction: HiddenFileSyncInitialisationDirection, + showMessage: boolean, + targetFiles?: string[] | false + ): Promise; +} + +/** Exact-revision operations needed by the Hatch repair pane. */ +export interface HiddenFileSyncRepairView { + scanInternalFiles(): Promise; + storeInternalFileToDatabase(file: InternalFileInfo, forceWrite?: boolean): Promise; + storeInternalFileToDatabaseWithBaseRevision( + file: InternalFileInfo, + baseRevision: string, + createIfDifferent?: boolean + ): Promise; + extractInternalFileRevisionFromDatabase( + storageFilePath: FilePath, + revision: string, + force?: boolean + ): Promise; +} + +/** Operations consumed by the host-owned Hidden File Sync commands. */ +export interface HiddenFileSyncCommandView extends HiddenFileSyncInitialisationView { + isManualCommandAvailable(): boolean; + scanAllStorageChanges(showNotice: boolean): Promise; + scanAllDatabaseChanges(showNotice: boolean): Promise; + applyOfflineChanges(showNotice: boolean): Promise; + updateSettingCache(): void; +} diff --git a/src/features/LiveSyncCommands.ts b/src/features/LiveSyncCommands.ts index 3f1ec783..a1c79392 100644 --- a/src/features/LiveSyncCommands.ts +++ b/src/features/LiveSyncCommands.ts @@ -1,93 +1,16 @@ -import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; -import { - LOG_LEVEL_INFO, - LOG_LEVEL_NOTICE, - type AnyEntry, - type DocumentID, - type FilePath, - type FilePathWithPrefix, - type LOG_LEVEL, -} from "@vrtmrz/livesync-commonlib/compat/common/types"; -import { MARK_DONE } from "@/modules/features/ModuleLog.ts"; import type { LiveSyncCore } from "@/main.ts"; // import { __$checkInstanceBinding } from "@vrtmrz/livesync-commonlib/compat/dev/checks"; -import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; - -let noticeIndex = 0; -export abstract class LiveSyncCommands { - core: LiveSyncCore; - get app() { - return this.services.context.app; - } - get settings() { - return this.core.settings; - } - get localDatabase() { - return this.core.localDatabase; - } - get services() { - return this.core.services; - } - - async path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise { - return await this.services.path.path2id(filename, prefix); - } - - getPath(entry: AnyEntry): FilePathWithPrefix { - return this.services.path.getPath(entry); - } +import { LiveSyncContext } from "./LiveSyncContext.ts"; +export abstract class LiveSyncCommands extends LiveSyncContext { constructor(core: LiveSyncCore) { - this.core = core; + super(core); this.onBindFunction(this.core, this.core.services); - this._log = createInstanceLogFunction(this.constructor.name, this.services.API); // __$checkInstanceBinding(this); } abstract onunload(): void; abstract onload(): void | Promise; - _isMainReady() { - return this.services.appLifecycle.isReady(); - } - _isMainSuspended() { - return this.services.appLifecycle.isSuspended(); - } - _isDatabaseReady() { - return this.services.database.isDatabaseReady(); - } - - _log: ReturnType; - - _verbose = (msg: unknown, key?: string) => { - this._log(msg, LOG_LEVEL_VERBOSE, key); - }; - - _info = (msg: unknown, key?: string) => { - this._log(msg, LOG_LEVEL_INFO, key); - }; - - _notice = (msg: unknown, key?: string) => { - this._log(msg, LOG_LEVEL_NOTICE, key); - }; - _progress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) => { - const key = `keepalive-progress-${noticeIndex++}`; - return { - log: (msg: string) => { - this._log(prefix + msg, level, key); - }, - once: (msg: string) => { - this._log(prefix + msg, level); - }, - done: (msg: string = "Done") => { - this._log(prefix + msg + MARK_DONE, level, key); - }, - }; - }; - - _debug = (msg: unknown, key?: string) => { - this._log(msg, LOG_LEVEL_VERBOSE, key); - }; - onBindFunction(core: LiveSyncCore, services: typeof core.services) { // Override if needed. } diff --git a/src/features/LiveSyncContext.ts b/src/features/LiveSyncContext.ts new file mode 100644 index 00000000..78dcd74d --- /dev/null +++ b/src/features/LiveSyncContext.ts @@ -0,0 +1,95 @@ +import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; +import { + LOG_LEVEL_INFO, + LOG_LEVEL_NOTICE, + type AnyEntry, + type DocumentID, + type FilePath, + type FilePathWithPrefix, + type LOG_LEVEL, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; + +import { MARK_DONE } from "@/modules/features/ModuleLog.ts"; +import type { LiveSyncCore } from "@/main.ts"; + +let noticeIndex = 0; + +/** Shared host access and logging helpers for stateful feature contexts. */ +export abstract class LiveSyncContext { + readonly core: LiveSyncCore; + + constructor(core: LiveSyncCore) { + this.core = core; + this._log = createInstanceLogFunction(this.constructor.name, this.services.API); + } + + get app() { + return this.services.context.app; + } + + get settings() { + return this.core.settings; + } + + get localDatabase() { + return this.core.localDatabase; + } + + get services() { + return this.core.services; + } + + async path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise { + return await this.services.path.path2id(filename, prefix); + } + + getPath(entry: AnyEntry): FilePathWithPrefix { + return this.services.path.getPath(entry); + } + + _isMainReady() { + return this.services.appLifecycle.isReady(); + } + + _isMainSuspended() { + return this.services.appLifecycle.isSuspended(); + } + + _isDatabaseReady() { + return this.services.database.isDatabaseReady(); + } + + _log: ReturnType; + + _verbose = (msg: unknown, key?: string) => { + this._log(msg, LOG_LEVEL_VERBOSE, key); + }; + + _info = (msg: unknown, key?: string) => { + this._log(msg, LOG_LEVEL_INFO, key); + }; + + _notice = (msg: unknown, key?: string) => { + this._log(msg, LOG_LEVEL_NOTICE, key); + }; + + _progress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) => { + const key = `keepalive-progress-${noticeIndex++}`; + return { + log: (msg: string) => { + this._log(prefix + msg, level, key); + }, + once: (msg: string) => { + this._log(prefix + msg, level); + }, + done: (msg: string = "Done") => { + this._log(prefix + msg + MARK_DONE, level, key); + }, + }; + }; + + _debug = (msg: unknown, key?: string) => { + this._log(msg, LOG_LEVEL_VERBOSE, key); + }; +} diff --git a/src/main.ts b/src/main.ts index a3216b86..72d20f8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,8 +2,6 @@ import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./de import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; setGetLanguage(getLanguage); import { LiveSyncCommands } from "./features/LiveSyncCommands.ts"; -import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts"; -import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts"; // import { ModuleDev } from "./modules/extras/ModuleDev.ts"; import { ModuleInteractiveConflictResolver } from "./modules/features/ModuleInteractiveConflictResolver.ts"; @@ -46,9 +44,13 @@ import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts"; import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts"; import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts"; +import { useCustomisationSyncUI } from "./serviceFeatures/useCustomisationSyncUI.ts"; +import { useOptionalFileSync, type OptionalFileSyncFeature } from "./serviceFeatures/useOptionalFileSync.ts"; +import { useHiddenFileSyncCommands } from "./serviceFeatures/useHiddenFileSyncCommands.ts"; export type LiveSyncCore = LiveSyncBaseCore; export default class ObsidianLiveSyncPlugin extends Plugin { core: LiveSyncCore; + optionalFileSync?: OptionalFileSyncFeature; /** * Initialise service modules. @@ -155,7 +157,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin { (core) => { const extraModules = [ new ModuleObsidianEvents(this, core), - new ModuleObsidianSettingDialogue(this, core), + new ModuleObsidianSettingDialogue(this, core, { + getHiddenFileSyncRepair: () => this.optionalFileSync?.hiddenFileSyncRepair, + }), new ModuleObsidianMenu(core), new ModuleObsidianSettingsAsMarkdown(core), new ModuleLog(this, core), @@ -169,8 +173,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { return extraModules; }, (core) => { - const addOns = [new ConfigSync(core), new HiddenFileSync(core), new LocalDatabaseMaintenance(core)]; - return addOns; + return [new LocalDatabaseMaintenance(core)]; }, (core) => { //TODO Fix: useXXXX @@ -202,6 +205,19 @@ export default class ObsidianLiveSyncPlugin extends Plugin { ); waitForCompatibilityReview = () => compatibilityReview.openReview(); useReviewHarness(core, this, compatibilityReview); + + let customisationSyncUI: ReturnType | undefined; + const optionalFileSync = useOptionalFileSync(core, { + getUIControl: () => customisationSyncUI, + }); + customisationSyncUI = useCustomisationSyncUI( + core, + this.app, + optionalFileSync.customisationSync, + optionalFileSync.hiddenFileSyncInitialisation + ); + useHiddenFileSyncCommands(core, optionalFileSync.hiddenFileSyncCommands); + this.optionalFileSync = optionalFileSync; } ); } diff --git a/src/managers/StorageEventManagerObsidian.ts b/src/managers/StorageEventManagerObsidian.ts index fa7eeeb4..65271d4e 100644 --- a/src/managers/StorageEventManagerObsidian.ts +++ b/src/managers/StorageEventManagerObsidian.ts @@ -1,15 +1,15 @@ import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type ObsidianLiveSyncPlugin from "@/main"; import type { LiveSyncCore } from "@/main"; -import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager"; +import { + StorageEventManagerBase, + type StorageEventManagerBaseDependencies, +} from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager"; import { ObsidianStorageEventManagerAdapter } from "./ObsidianStorageEventManagerAdapter"; export class StorageEventManagerObsidian extends StorageEventManagerBase { core: LiveSyncCore; - // Necessary evil. - // cmdHiddenFileSync: HiddenFileSync; - constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, dependencies: StorageEventManagerBaseDependencies) { const adapter = new ObsidianStorageEventManagerAdapter(plugin, core, dependencies.fileProcessing); super(adapter, dependencies); @@ -22,6 +22,8 @@ export class StorageEventManagerObsidian extends StorageEventManagerBase 0) return; if (!path.startsWith(this.core.services.API.getSystemConfigDir())) return; if (path.endsWith("/")) { // Folder @@ -30,6 +32,15 @@ export class StorageEventManagerObsidian extends StorageEventManagerBase