Refactor optional file synchronisation ownership

This commit is contained in:
vorotamoroz
2026-09-03 15:43:21 +00:00
parent 3b2d5aa5af
commit 826413bf84
67 changed files with 6294 additions and 1856 deletions
+2
View File
@@ -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;
@@ -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)
@@ -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.
+1 -1
View File
@@ -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
-13
View File
@@ -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<T extends TCommands>(cls: string) {
for (const addon of this.addOns) {
if (addon.constructor.name == cls) return addon as T;
}
return undefined;
}
constructor(
serviceHub: InjectableServiceHub<T>,
serviceModuleInitialiser: (
@@ -304,5 +292,4 @@ export class LiveSyncBaseCore<
export interface IMinimumLiveSyncCommands {
onunload(): void;
onload(): void | Promise<void>;
constructor: { name: string };
}
@@ -363,18 +363,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "新增连接",
"zh-tw": "新增連線",
},
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": {
def: "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
es: "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
ko: "애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
"zh-tw": "附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。",
},
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": {
def: "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.",
es: "El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
ko: "애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
"zh-tw": "附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。",
},
Advanced: {
def: "Advanced",
es: "Avanzado",
-2
View File
@@ -46,8 +46,6 @@
"Active Remote Configuration": "Active Remote Configuration",
"Add default patterns": "Add default patterns",
"Add new connection": "Add new connection",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.",
"Advanced": "Advanced",
"Advanced Settings": "Advanced Settings",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.",
-2
View File
@@ -46,8 +46,6 @@
"Active Remote Configuration": "Configuración remota activa",
"Add default patterns": "Añadir patrones predeterminados",
"Add new connection": "Añadir conexión",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
"Advanced": "Avanzado",
"Advanced Settings": "Ajustes avanzados",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "Tras reiniciar, los datos de este dispositivo se subirán al servidor como «copia maestra». Ten en cuenta que cualquier dato no deseado que haya ahora en el servidor se sobrescribirá por completo.",
-2
View File
@@ -46,8 +46,6 @@
"Active Remote Configuration": "활성 원격 구성",
"Add default patterns": "기본 패턴 추가",
"Add new connection": "연결 추가",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
"Advanced": "고급",
"Advanced Settings": "고급 설정",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "재시작하면 이 기기의 데이터가 '원본'으로서 서버에 업로드됩니다. 현재 서버에 있는 의도치 않은 데이터는 모두 완전히 덮어써진다는 점에 유의해 주세요.",
-2
View File
@@ -46,8 +46,6 @@
"Active Remote Configuration": "目前啟用的遠端設定",
"Add default patterns": "新增預設模式",
"Add new connection": "新增連線",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。",
"Advanced": "進階",
"Advanced Settings": "進階設定",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "重新啟動後,此裝置上的資料將以「主要複本」的形式上傳到伺服器。請注意,伺服器上任何非預期的現有資料都會被完全覆寫。",
-6
View File
@@ -58,12 +58,6 @@ Activate: Activate
Active Remote Configuration: Active Remote Configuration
Add default patterns: Add default patterns
Add new connection: Add new connection
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.:
AddOn Module (ConfigSync) has not been loaded. This is very unexpected
situation. Please report this issue.
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.:
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected
situation. Please report this issue.
Advanced: Advanced
Advanced Settings: Advanced Settings
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
-6
View File
@@ -57,12 +57,6 @@ Activate: Activar
Active Remote Configuration: Configuración remota activa
Add default patterns: Añadir patrones predeterminados
Add new connection: Añadir conexión
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.:
El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy
inesperada. Informa de este problema.
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.:
El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es
muy inesperada. Informa de este problema.
Advanced: Avanzado
Advanced Settings: Ajustes avanzados
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
-2
View File
@@ -47,8 +47,6 @@ Activate: 활성화
Active Remote Configuration: 활성 원격 구성
Add default patterns: 기본 패턴 추가
Add new connection: 연결 추가
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.: 애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.: 애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.
Advanced: 고급
Advanced Settings: 고급 설정
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.: 재시작하면 이 기기의 데이터가 '원본'으로서 서버에 업로드됩니다. 현재 서버에 있는 의도치 않은 데이터는 모두 완전히 덮어써진다는 점에 유의해 주세요.
-2
View File
@@ -139,8 +139,6 @@ username: 使用者名稱
"↑: Overwrite Remote": ↑:覆寫遠端
"↓: Overwrite Local": ↓:覆寫本機
"⇅: Use newer": ⇅:使用較新版本
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.: 附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.: 附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。
All the same or non-existent: 全部相同或不存在
Apply All Selected: 套用所有已選取項目
Automatic: 自動
@@ -1,113 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
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();
});
});
+11 -31
View File
@@ -1,15 +1,9 @@
<script lang="ts">
import {
ConfigSync,
PluginDataExDisplayV2,
type IPluginDataExDisplay,
type PluginDataExFile,
} from "./CmdConfigSync.ts";
import type { CustomisationSyncDialogView, IPluginDataExDisplay } from "./customisationSyncView.ts";
import type { PluginDataExFile } from "./customisationSyncCodec.ts";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getDocData, timeDeltaToHumanReadable, unique } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type ObsidianLiveSyncPlugin from "@/main";
// import { askString } from "../../common/utils";
import { Menu } from "@/deps.ts";
import { $msg as translateMessage } from "@/common/translation";
@@ -28,15 +22,9 @@
) => Promise<boolean>;
export let deleteData: (data: IPluginDataExDisplay) => Promise<boolean>;
export let hidden: boolean;
export let plugin: ObsidianLiveSyncPlugin;
export let customisationSync: CustomisationSyncDialogView;
export let isMaintenanceMode: boolean = false;
export let isFlagged: boolean = false;
$: core = plugin.core;
const addOn = plugin.core.getAddOn<ConfigSync>(ConfigSync.name)!;
if (!addOn) {
Logger(`Could not load the add-on ${ConfigSync.name}`, LOG_LEVEL_INFO);
throw new Error(`Could not load the add-on ${ConfigSync.name}`);
}
export let selected = "";
let freshness = "";
@@ -263,7 +251,7 @@
const local = list.find((e) => e.term == thisTerm);
const selectedItem = list.find((e) => e.term == selected);
if (selectedItem && (await applyData(selectedItem))) {
addOn.updatePluginList(true, local?.documentPath);
void customisationSync.updatePluginList(true, local?.documentPath);
}
}
async function compareSelected() {
@@ -279,18 +267,12 @@
if (local && remote) {
if (!filename) {
if (await compareData(local, remote)) {
addOn.updatePluginList(true, local.documentPath);
void customisationSync.updatePluginList(true, local.documentPath);
}
return;
} else {
const localCopy =
local instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(local) : { ...local };
const remoteCopy =
remote instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(remote) : { ...remote };
localCopy.files = localCopy.files.filter((e) => e.filename == filename);
remoteCopy.files = remoteCopy.files.filter((e) => e.filename == filename);
if (await compareData(localCopy, remoteCopy, true)) {
addOn.updatePluginList(true, local.documentPath);
if (await customisationSync.compareFileUsingDisplayData(local, remote, filename)) {
void customisationSync.updatePluginList(true, local.documentPath);
}
}
return;
@@ -333,7 +315,7 @@
const selectedItem = list.find((e) => e.term == selected);
// const deletedPath = selectedItem.documentPath;
if (selectedItem && (await deleteData(selectedItem))) {
addOn.reloadPluginList(true);
void customisationSync.reloadPluginList(true);
}
}
async function duplicateItem() {
@@ -342,7 +324,7 @@
Logger(`Could not find local item`, LOG_LEVEL_VERBOSE);
return;
}
const duplicateTermName = await core.confirm.askString(
const duplicateTermName = await customisationSync.askString(
translateMessage("Duplicate"),
translateMessage("device name"),
""
@@ -352,9 +334,7 @@
Logger(translateMessage('We can not use "/" to the device name'), LOG_LEVEL_NOTICE);
return;
}
const key = `${plugin.core.services.API.getSystemConfigDir()}/${local.files[0].filename}`;
await addOn.storeCustomizationFiles(key as FilePath, duplicateTermName);
await addOn.updatePluginList(false, addOn.filenameToUnifiedKey(key, duplicateTermName));
await customisationSync.duplicateData(local, duplicateTermName);
}
}
</script>
+16 -6
View File
@@ -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<typeof mount> | 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,
},
});
}
}
@@ -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);
});
});
+36 -80
View File
@@ -1,14 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import ObsidianLiveSyncPlugin from "@/main";
import {
ConfigSync,
type IPluginDataExDisplay,
pluginIsEnumerating,
pluginList,
pluginManifestStore,
pluginV2Progress,
} from "./CmdConfigSync.ts";
import type { CustomisationSyncDialogView, IPluginDataExDisplay } from "./customisationSyncView.ts";
import PluginCombo from "./PluginCombo.svelte";
import { Menu, type PluginManifest } from "@/deps.ts";
import { unique } from "@vrtmrz/livesync-commonlib/compat/common/utils";
@@ -19,38 +11,19 @@
type SYNC_MODE,
MODE_SHINY,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { normalizePath } from "@/deps";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import type { HiddenFileSyncInitialisationView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
import { $msg as translateMessage } from "@/common/translation";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
export let plugin: ObsidianLiveSyncPlugin;
export let core :LiveSyncBaseCore;
// $: core = plugin.core;
export let customisationSync: CustomisationSyncDialogView;
export let hiddenFileSync: HiddenFileSyncInitialisationView;
$: hideNotApplicable = false;
$: thisTerm = core.services.setting.getDeviceAndVaultName();
$: thisTerm = customisationSync.getDeviceAndVaultName();
const addOn = core.getAddOn<ConfigSync>(ConfigSync.name)!;
if (!addOn) {
const msg = translateMessage(
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue."
);
Logger(msg, LOG_LEVEL_NOTICE);
throw new Error(msg);
}
const addOnHiddenFileSync = core.getAddOn<HiddenFileSync>(HiddenFileSync.name) as HiddenFileSync;
if (!addOnHiddenFileSync) {
const msg = translateMessage(
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue."
);
Logger(msg, LOG_LEVEL_NOTICE);
throw new Error(msg);
}
const catalogue = customisationSync.catalogue;
const enumerationActive = customisationSync.enumerationActive;
const migrationProgress = customisationSync.migrationProgress;
const manifests = customisationSync.manifests;
let list: IPluginDataExDisplay[] = [];
@@ -61,21 +34,17 @@
let applyAllPluse = 0;
let isMaintenanceMode = false;
async function requestUpdate() {
await addOn.updatePluginList(true);
await customisationSync.updatePluginList(true);
}
async function requestReload() {
await addOn.reloadPluginList(true);
await customisationSync.reloadPluginList(true);
}
let allTerms = [] as string[];
pluginList.subscribe((e) => {
list = e;
allTerms = unique(list.map((e) => e.term));
});
pluginIsEnumerating.subscribe((e) => {
loading = e;
});
onMount(async () => {
requestUpdate();
let allTerms: string[] = [];
$: list = $catalogue;
$: allTerms = unique(list.map((entry) => entry.term));
$: loading = $enumerationActive;
onMount(() => {
void requestUpdate();
});
function filterList(list: IPluginDataExDisplay[], categories: string[]) {
@@ -104,15 +73,11 @@
SNIPPET: translateMessage("Snippets"),
};
async function scanAgain() {
await addOn.scanAllConfigFiles(true);
await customisationSync.scanAllConfigFiles(true);
await requestUpdate();
}
async function replicate() {
await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
await customisationSync.synchronise();
}
function selectAllNewest(selectMode: boolean) {
selectNewestPulse++;
@@ -126,17 +91,17 @@
applyAllPluse++;
}
async function applyData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.applyData(data);
return await customisationSync.applyData(data);
}
async function compareData(
docA: IPluginDataExDisplay,
docB: IPluginDataExDisplay,
compareEach = false
): Promise<boolean> {
return await addOn.compareUsingDisplayData(docA, docB, compareEach);
return await customisationSync.compareUsingDisplayData(docA, docB, compareEach);
}
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.deleteData(data);
return await customisationSync.deleteData(data);
}
function askMode(evt: MouseEvent, title: string, key: string) {
const menu = new Menu();
@@ -161,9 +126,8 @@
}
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
setMode(key, MODE_AUTOMATIC);
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
const files = customisationSync.getConfiguredTargetFiles(key);
void hiddenFileSync.initialiseInternalFileSync(direction, true, files);
}
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
const menu = new Menu();
@@ -196,7 +160,7 @@
applyData,
compareData,
deleteData,
plugin,
customisationSync,
isMaintenanceMode,
};
@@ -236,22 +200,12 @@
);
if (mode == MODE_SELECTIVE) {
automaticList.delete(key);
delete plugin.core.settings.pluginSyncExtendedSetting[key];
automaticListDisp = automaticList;
} else {
automaticList.set(key, mode);
automaticListDisp = automaticList;
if (!(key in plugin.core.settings.pluginSyncExtendedSetting)) {
plugin.core.settings.pluginSyncExtendedSetting[key] = {
key,
mode,
files: [],
};
}
plugin.core.settings.pluginSyncExtendedSetting[key].files = files;
plugin.core.settings.pluginSyncExtendedSetting[key].mode = mode;
}
core.services.setting.saveSettingData();
customisationSync.updateConfiguredMode(key, mode, files);
}
function getIcon(mode: SYNC_MODE) {
if (mode in ICONS) {
@@ -264,7 +218,7 @@
let automaticListDisp = new Map<string, SYNC_MODE>();
// apply current configuration to the dialogue
for (const { key, mode } of Object.values(plugin.core.settings.pluginSyncExtendedSetting)) {
for (const { key, mode } of customisationSync.getConfiguredModes()) {
automaticList.set(key, mode);
}
@@ -273,7 +227,7 @@
let displayKeys: Record<string, string[]> = {};
function computeDisplayKeys(list: IPluginDataExDisplay[]) {
const extraKeys = Object.keys(plugin.core.settings.pluginSyncExtendedSetting);
const extraKeys = customisationSync.getConfiguredModes().map(({ key }) => key);
return [
...list,
...extraKeys
@@ -303,7 +257,7 @@
for (const item of deleteItems) {
await deleteData(item);
}
addOn.reloadPluginList(true);
void customisationSync.reloadPluginList(true);
}
let nameMap = new Map<string, string>();
@@ -324,7 +278,7 @@
}
nameMap = newMap;
}
$: updateNameMap($pluginManifestStore);
$: updateNameMap($manifests);
let displayEntries = [] as [string, string][];
$: {
@@ -335,7 +289,7 @@
$: {
pluginEntries = groupBy(filterList(list, ["PLUGIN_MAIN", "PLUGIN_DATA", "PLUGIN_ETC"]), "name");
}
let useSyncPluginEtc = plugin.core.settings.usePluginEtc;
let useSyncPluginEtc = customisationSync.isPluginEtcEnabled();
</script>
<div class="buttonsWrap">
@@ -357,8 +311,10 @@
</div>
</div>
<div class="loading">
{#if loading || $pluginV2Progress !== 0}
<span>{translateMessage("Updating list...")}{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`}</span>
{#if loading || $migrationProgress !== 0}
<span
>{translateMessage("Updating list...")}{$migrationProgress == 0 ? "" : ` (${$migrationProgress})`}</span
>
{/if}
</div>
<div class="list">
@@ -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<T>(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 };
}
@@ -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<PluginDataEx>(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");
});
});
@@ -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);
});
});
@@ -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",
});
});
});
@@ -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();
});
});
@@ -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();
});
});
@@ -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));
});
});
@@ -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> = {}
): 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 };
}
@@ -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<string, PluginSyncSettingEntry> = {
"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<boolean>
>(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");
});
});
@@ -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 };
}
@@ -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",
});
});
});
@@ -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<");
});
});
@@ -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<IPluginDataExDisplay[]>;
readonly enumerationActive: Readable<boolean>;
readonly migrationProgress: Readable<number>;
readonly manifests: Readable<Map<string, PluginManifest>>;
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<void>;
reloadPluginList(showMessage: boolean): Promise<void>;
scanAllConfigFiles(showMessage: boolean): Promise<void>;
synchronise(): Promise<void>;
applyData(data: IPluginDataExDisplay): Promise<boolean>;
compareUsingDisplayData(
dataA: IPluginDataExDisplay,
dataB: IPluginDataExDisplay,
compareEach?: boolean
): Promise<boolean>;
compareFileUsingDisplayData(
dataA: IPluginDataExDisplay,
dataB: IPluginDataExDisplay,
filename: string
): Promise<boolean>;
deleteData(data: IPluginDataExDisplay): Promise<boolean>;
duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise<void>;
askString(title: string, key: string, placeholder: string): Promise<string | false>;
}
/** Narrow control returned by the host-owned dialogue composition feature. */
export interface CustomisationSyncUIControl {
open(): void;
close(): void;
isOpen(): boolean;
}
@@ -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();
});
});
@@ -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);
});
});
@@ -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();
}
);
});
@@ -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<boolean>) => 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();
});
});
@@ -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<void>;
}
/** Exact-revision operations needed by the Hatch repair pane. */
export interface HiddenFileSyncRepairView {
scanInternalFiles(): Promise<InternalFileInfo[]>;
storeInternalFileToDatabase(file: InternalFileInfo, forceWrite?: boolean): Promise<boolean | undefined>;
storeInternalFileToDatabaseWithBaseRevision(
file: InternalFileInfo,
baseRevision: string,
createIfDifferent?: boolean
): Promise<boolean>;
extractInternalFileRevisionFromDatabase(
storageFilePath: FilePath,
revision: string,
force?: boolean
): Promise<boolean>;
}
/** Operations consumed by the host-owned Hidden File Sync commands. */
export interface HiddenFileSyncCommandView extends HiddenFileSyncInitialisationView {
isManualCommandAvailable(): boolean;
scanAllStorageChanges(showNotice: boolean): Promise<unknown>;
scanAllDatabaseChanges(showNotice: boolean): Promise<unknown>;
applyOfflineChanges(showNotice: boolean): Promise<unknown>;
updateSettingCache(): void;
}
+3 -80
View File
@@ -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<DocumentID> {
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<void>;
_isMainReady() {
return this.services.appLifecycle.isReady();
}
_isMainSuspended() {
return this.services.appLifecycle.isSuspended();
}
_isDatabaseReady() {
return this.services.database.isDatabaseReady();
}
_log: ReturnType<typeof createInstanceLogFunction>;
_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.
}
+95
View File
@@ -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<DocumentID> {
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<typeof createInstanceLogFunction>;
_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);
};
}
+21 -5
View File
@@ -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<ObsidianServiceContext, LiveSyncCommands>;
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<typeof useCustomisationSyncUI> | undefined;
const optionalFileSync = useOptionalFileSync(core, {
getUIControl: () => customisationSyncUI,
});
customisationSyncUI = useCustomisationSyncUI(
core,
this.app,
optionalFileSync.customisationSync,
optionalFileSync.hiddenFileSyncInitialisation
);
useHiddenFileSyncCommands(core, optionalFileSync.hiddenFileSyncCommands);
this.optionalFileSync = optionalFileSync;
}
);
}
+15 -4
View File
@@ -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<ObsidianStorageEventManagerAdapter> {
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<Obsidia
protected override async _watchVaultRawEvents(path: FilePath) {
if (!this.settings.syncInternalFiles && !this.settings.usePluginSync) return;
if (!this.settings.watchInternalFileChanges) return;
if (!this.settings.isConfigured || this.settings.suspendFileWatching) return;
if (this.settings.maxMTimeForReflectEvents > 0) return;
if (!path.startsWith(this.core.services.API.getSystemConfigDir())) return;
if (path.endsWith("/")) {
// Folder
@@ -30,6 +32,15 @@ export class StorageEventManagerObsidian extends StorageEventManagerBase<Obsidia
const isTargetFile = await this.vaultService.isTargetFileInExtra(path);
if (!isTargetFile) return;
// Commonlib's ordinary queue excludes every dot path when Hidden File
// Sync is disabled. A Customisation-only event has already passed the
// optional-file policy, so dispatch it without that unrelated gate.
if (!this.settings.syncInternalFiles) {
this.fileProcessing.onStorageFileEvent();
await this.fileProcessing.processOptionalFileEvent(path);
return;
}
void this.appendQueue(
[
{
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager", () => ({
StorageEventManagerBase: class StorageEventManagerBase {
settings: unknown;
vaultService: unknown;
fileProcessing: unknown;
adapter: unknown;
appendQueue = vi.fn(async () => undefined);
constructor(adapter: unknown, dependencies: Record<string, unknown>) {
this.adapter = adapter;
this.settings = dependencies.settings;
this.vaultService = dependencies.vaultService;
this.fileProcessing = dependencies.fileProcessing;
}
},
}));
vi.mock("./ObsidianStorageEventManagerAdapter", () => ({
ObsidianStorageEventManagerAdapter: class ObsidianStorageEventManagerAdapter {
converter = {
toInternalFileInfo: vi.fn((path: FilePath) => ({ path, isInternal: true, isFolder: false })),
};
},
}));
import { StorageEventManagerObsidian } from "./StorageEventManagerObsidian.ts";
function createManager(
options: {
customisationEnabled?: boolean;
hiddenFileEnabled?: boolean;
target?: boolean;
configured?: boolean;
suspended?: boolean;
maxMTime?: number;
} = {}
) {
const settings = {
usePluginSync: options.customisationEnabled ?? true,
syncInternalFiles: options.hiddenFileEnabled ?? true,
watchInternalFileChanges: true,
isConfigured: options.configured ?? true,
suspendFileWatching: options.suspended ?? false,
maxMTimeForReflectEvents: options.maxMTime ?? 0,
};
const onStorageFileEvent = vi.fn();
const processOptionalFileEvent = vi.fn(async () => true);
const isTargetFileInExtra = vi.fn(async () => options.target ?? true);
const core = {
services: {
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
},
};
const manager = new StorageEventManagerObsidian(
{} as never,
core as never,
{
settings,
vaultService: { isTargetFileInExtra },
fileProcessing: { onStorageFileEvent, processOptionalFileEvent },
} as never
);
const runRawEvent = (path: string) =>
(manager as unknown as { _watchVaultRawEvents(path: FilePath): Promise<void> })._watchVaultRawEvents(
path as FilePath
);
return {
appendQueue: (manager as unknown as { appendQueue: ReturnType<typeof vi.fn> }).appendQueue,
isTargetFileInExtra,
onStorageFileEvent,
processOptionalFileEvent,
runRawEvent,
};
}
describe("StorageEventManagerObsidian optional-file raw events", () => {
it("dispatches a Customisation-only event without the base Hidden File Sync target gate", async () => {
const fixture = createManager({ hiddenFileEnabled: false });
await fixture.runRawEvent(".obsidian/app.json");
expect(fixture.processOptionalFileEvent).toHaveBeenCalledWith(".obsidian/app.json");
expect(fixture.onStorageFileEvent).toHaveBeenCalledOnce();
expect(fixture.appendQueue).not.toHaveBeenCalled();
});
it("retains the base queue for events while Hidden File Sync is enabled", async () => {
const fixture = createManager();
await fixture.runRawEvent(".obsidian/workspace");
expect(fixture.appendQueue).toHaveBeenCalledOnce();
expect(fixture.processOptionalFileEvent).not.toHaveBeenCalled();
});
it("does not dispatch disabled, filtered, folder, or out-of-directory paths", async () => {
const disabled = createManager({ customisationEnabled: false, hiddenFileEnabled: false });
await disabled.runRawEvent(".obsidian/app.json");
expect(disabled.appendQueue).not.toHaveBeenCalled();
const filtered = createManager({ target: false });
await filtered.runRawEvent(".obsidian/workspace");
await filtered.runRawEvent(".obsidian/folder/");
await filtered.runRawEvent("notes/example.md");
expect(filtered.appendQueue).not.toHaveBeenCalled();
expect(filtered.processOptionalFileEvent).not.toHaveBeenCalled();
});
it.each([
["unconfigured", { configured: false }],
["suspended", { suspended: true }],
["bounded by reflection time", { maxMTime: 1 }],
] as const)("preserves the base queue gate while %s", async (_label, options) => {
const fixture = createManager({ hiddenFileEnabled: false, ...options });
await fixture.runRawEvent(".obsidian/app.json");
expect(fixture.isTargetFileInExtra).not.toHaveBeenCalled();
expect(fixture.processOptionalFileEvent).not.toHaveBeenCalled();
});
});
@@ -2,14 +2,33 @@ import { ObsidianLiveSyncSettingTab } from "./SettingDialogue/ObsidianLiveSyncSe
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
import { EVENT_REQUEST_OPEN_SETTINGS, eventHub } from "@/common/events.ts";
import type ObsidianLiveSyncPlugin from "@/main.ts";
import type { LiveSyncCore } from "@/main.ts";
import { openObsidianSettings } from "@/common/obsidianSettings.ts";
import type { HiddenFileSyncRepairView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
export type ModuleObsidianSettingDialogueDependencies = {
getHiddenFileSyncRepair(): HiddenFileSyncRepairView | undefined;
};
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
private readonly dependencies: ModuleObsidianSettingDialogueDependencies;
settingTab!: ObsidianLiveSyncSettingTab;
constructor(
plugin: ObsidianLiveSyncPlugin,
core: LiveSyncCore,
dependencies: Partial<ModuleObsidianSettingDialogueDependencies> = {}
) {
super(plugin, core);
this.dependencies = {
getHiddenFileSyncRepair: dependencies.getHiddenFileSyncRepair ?? (() => undefined),
};
}
_everyOnloadAfterLoadSettings(): Promise<boolean> {
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
const hiddenFileSyncRepair = this.dependencies.getHiddenFileSyncRepair();
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin, { hiddenFileSyncRepair });
this.settingTab.reloadAllSettings(true);
this.plugin.addSettingTab(this.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const settingTabState = vi.hoisted(() => ({
callOrder: [] as string[],
featureViews: undefined as unknown,
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
}));
@@ -11,6 +12,10 @@ const eventHubState = vi.hoisted(() => ({
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
constructor(_app: unknown, _plugin: unknown, featureViews: unknown) {
settingTabState.featureViews = featureViews;
}
reloadAllSettings(skipUpdate?: boolean) {
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
settingTabState.reloadAllSettings(skipUpdate);
@@ -46,15 +51,20 @@ function createModuleHarness() {
},
},
};
const hiddenFileSyncRepair = { scanInternalFiles: vi.fn() };
const getHiddenFileSyncRepair = vi.fn(() => hiddenFileSyncRepair);
const module = Object.assign(Object.create(ModuleObsidianSettingDialogue.prototype), {
plugin,
core: { services },
dependencies: { getHiddenFileSyncRepair },
}) as ModuleObsidianSettingDialogue;
module.onBindFunction(module.core as never, services as never);
return {
initialisationHandler: () => initialisationHandler,
getHiddenFileSyncRepair,
hiddenFileSyncRepair,
module,
plugin,
services,
@@ -65,6 +75,7 @@ function createModuleHarness() {
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
beforeEach(() => {
settingTabState.callOrder.length = 0;
settingTabState.featureViews = undefined;
settingTabState.reloadAllSettings.mockClear();
eventHubState.onEvent.mockClear();
});
@@ -79,7 +90,8 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
});
it("seeds the setting editor without requesting a render before registration", async () => {
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
const { getHiddenFileSyncRepair, hiddenFileSyncRepair, initialisationHandler, settingsLoadedHandler } =
createModuleHarness();
const handler = settingsLoadedHandler() ?? initialisationHandler();
expect(handler).toBeTypeOf("function");
@@ -87,5 +99,7 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
expect(getHiddenFileSyncRepair).toHaveBeenCalledOnce();
expect(settingTabState.featureViews).toEqual({ hiddenFileSyncRepair });
});
});
@@ -14,7 +14,7 @@ import {
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { testCrypt } from "octagonal-wheels/encryption/encryption";
import ObsidianLiveSyncPlugin from "@/main.ts";
import type ObsidianLiveSyncPlugin from "@/main.ts";
import { scheduleTask } from "@/common/utils.ts";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import {
@@ -78,12 +78,18 @@ import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
import type { HiddenFileSyncRepairView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
// For creating a document
// const toc = new Set<string>();
export type SettingTabFeatureViews = {
hiddenFileSyncRepair?: HiddenFileSyncRepairView;
};
export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
plugin: ObsidianLiveSyncPlugin;
readonly featureViews: SettingTabFeatureViews;
private _lifetimeComponent?: Component;
private activePageRefresh?: () => void;
get lifetimeComponent(): Component {
@@ -323,9 +329,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
controlledElementFunc = [] as UpdateFunction[];
onSavedHandlers = [] as OnSavedHandler<AllSettingItemKey>[];
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
constructor(app: App, plugin: ObsidianLiveSyncPlugin, featureViews: SettingTabFeatureViews = {}) {
super(app, plugin);
this.plugin = plugin;
this.featureViews = featureViews;
Setting.env = this;
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
this.requestReload();
@@ -67,8 +67,6 @@ export function paneCustomisationSync(
.setButtonText("Open")
.setDisabled(false)
.onClick(() => {
// this.plugin.getAddOn<ConfigSync>(ConfigSync.name)?.showPluginSyncModal();
// this.plugin.addOnConfigSync.showPluginSyncModal();
eventHub.emitEvent(EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG);
});
})
@@ -21,7 +21,6 @@ import {
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
eventHub,
} from "@/common/events.ts";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
@@ -339,22 +338,22 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
]);
};
const findHiddenFile = async (path: string) => {
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
if (!addOn) {
const repair = this.featureViews.hiddenFileSyncRepair;
if (!repair) {
return false;
}
const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path);
const file = (await repair.scanInternalFiles()).find((entry) => entry.path === path);
if (!file) {
Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE);
return false;
}
return { addOn, file };
return { repair, file };
};
const storeStorageInDatabase = async (path: string): Promise<boolean> => {
if (path.startsWith(".")) {
const hidden = await findHiddenFile(path);
return hidden
? Boolean(await hidden.addOn.storeInternalFileToDatabase(hidden.file, true))
? Boolean(await hidden.repair.storeInternalFileToDatabase(hidden.file, true))
: false;
}
return Boolean(await this.core.fileHandler.storeFileToDB(path as FilePath, true));
@@ -368,7 +367,7 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
const hidden = await findHiddenFile(path);
return hidden
? Boolean(
await hidden.addOn.storeInternalFileToDatabaseWithBaseRevision(
await hidden.repair.storeInternalFileToDatabaseWithBaseRevision(
hidden.file,
revision,
createIfDifferent
@@ -390,10 +389,10 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
force: boolean
): Promise<boolean> => {
if (path.startsWith(".")) {
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
return addOn
const repair = this.featureViews.hiddenFileSyncRepair;
return repair
? Boolean(
await addOn.extractInternalFileRevisionFromDatabase(
await repair.extractInternalFileRevisionFromDatabase(
path as FilePath,
revision,
force
@@ -0,0 +1,152 @@
import {
CANCELLED,
LEAVE_TO_SUBSEQUENT,
LOG_LEVEL_NOTICE,
type FilePath,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { pluginScanningCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
import { Platform } from "@/deps.ts";
import type { CustomisationSyncContextDependencies } from "@/features/ConfigSync/customisationSyncContext.ts";
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
import type { LiveSyncCore } from "@/main.ts";
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
export type CustomisationSyncObsidianPolicies = Pick<
CustomisationSyncContextDependencies,
"getUIControl" | "ownsLocalDocument" | "ownsLocalFile"
>;
function fallbackDeviceName(): string {
let name: string;
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";
}
return name + Math.random().toString(36).slice(-4);
}
/**
* Adapt the Obsidian host to the narrow capabilities used by Customisation Sync.
*
* Runtime state and synchronisation policy remain in the context. Obsidian UI,
* host lifecycle, and compatibility telemetry stay at this composition edge.
*/
export function createCustomisationSyncObsidianDependencies(
host: LiveSyncCore,
policies: CustomisationSyncObsidianPolicies
): CustomisationSyncContextDependencies {
const { services } = host;
const app = services.context.app;
const log = createInstanceLogFunction("CustomisationSyncContext", services.API);
return {
getSettings: () => services.setting.settings,
getLocalDatabase: () => services.database.localDatabase,
storageAccess: host.serviceModules.storageAccess,
path: services.path,
log,
getConfigDir: () => services.API.getSystemConfigDir(),
getDeviceAndVaultName: () => services.setting.getDeviceAndVaultName(),
setDeviceAndVaultName: (name) => services.setting.setDeviceAndVaultName(name),
saveSettingData: async () => await services.setting.saveSettingData(),
applySettings: async (partial, saveImmediately) =>
await services.setting.applyPartial(partial, saveImmediately),
replicateUserInitiated: async (options) => await services.replication.replicateUserInitiated(options),
askString: async (title, key, placeholder) => await services.UI.confirm.askString(title, key, placeholder),
isReady: () => services.appLifecycle.isReady(),
isSuspended: () => services.appLifecycle.isSuspended(),
askRestart: () => services.appLifecycle.askRestart(),
createPeriodicProcessor: (process) => new PeriodicProcessor(host, process),
listFiles: async (path) => await app.vault.adapter.list(path),
resolveJsonConflict: async (path, files, remoteName, apply) =>
await new Promise<boolean>((resolve) => {
const modal = new JsonResolveModal(
app,
path,
files,
async (_keep, result) => {
if (result == null) {
resolve(false);
return;
}
resolve(await apply(result));
},
"Local",
remoteName,
"B",
true,
true,
"Difference between local and remote"
);
modal.open();
}),
selectTextFile: async (path, diffResult, remoteName) => {
const modal = new ConflictResolveModal(app, path, diffResult, true, remoteName);
modal.open();
const result = await modal.waitForResult();
if (result === CANCELLED || result === LEAVE_TO_SUBSEQUENT) return false;
if (result === "A" || result === "B") return result;
return false;
},
reloadPlugin: async (configDir, pluginName) => {
const pluginManager = getObsidianCommunityPluginManager(app);
const pluginManifest = pluginManager.manifests.find(
(manifest) =>
pluginManager.enabledPlugins.has(manifest.id) &&
manifest.dir == `${configDir}/plugins/${pluginName}`
);
if (!pluginManifest) return;
const logKey = "plugin-reload-" + pluginManifest.id;
log(`Unloading plugin: ${pluginManifest.name}`, LOG_LEVEL_NOTICE, logKey);
await pluginManager.unloadPlugin(pluginManifest.id);
await pluginManager.loadPlugin(pluginManifest.id);
log(`Plugin reloaded: ${pluginManifest.name}`, LOG_LEVEL_NOTICE, logKey);
},
getFallbackDeviceName: fallbackDeviceName,
showConfigurationNotice: (openDialog) => {
const fragment = createFragment((documentFragment) => {
documentFragment.createSpan(undefined, (span) => {
span.appendText("Some configuration has been arrived, Press ");
span.appendChild(
span.createEl("a", undefined, (anchor) => {
anchor.text = "HERE";
anchor.addEventListener("click", openDialog);
})
);
span.appendText(" to open the config sync dialog , or press elsewhere to dismiss this message.");
});
});
services.context.notices.show(UPDATED_CONFIGURATION_NOTICE_KEY, fragment, { durationMs: 20_000 });
},
hideConfigurationNotice: () => services.context.notices.hide(UPDATED_CONFIGURATION_NOTICE_KEY),
getUIControl: policies.getUIControl,
ownsLocalFile: (path: FilePath) => policies.ownsLocalFile(path),
ownsLocalDocument: (path: FilePathWithPrefix) => policies.ownsLocalDocument(path),
publishScanCount: (count) => {
pluginScanningCount.value = count;
},
};
}
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CANCELLED,
LOG_LEVEL_NOTICE,
type FilePath,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
const mocks = vi.hoisted(() => ({
conflictResult: "B" as string | symbol,
conflictArguments: [] as unknown[],
conflictOpen: vi.fn(),
jsonArguments: [] as unknown[],
jsonOpen: vi.fn(),
jsonResult: "merged",
log: vi.fn(),
manager: {
enabledPlugins: new Set(["example"]),
manifests: [{ id: "example", name: "Example", dir: ".obsidian/plugins/example" }],
loadPlugin: vi.fn(async () => undefined),
unloadPlugin: vi.fn(async () => undefined),
},
periodicArguments: [] as unknown[],
platform: {
isAndroidApp: false,
isIosApp: false,
isMacOS: true,
isMobileApp: false,
isMobile: false,
isSafari: false,
isDesktop: true,
isDesktopApp: true,
},
scanCount: { value: 0 },
}));
vi.mock("@/deps.ts", () => ({ Platform: mocks.platform }));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {
constructor(...args: unknown[]) {
mocks.periodicArguments = args;
}
enable() {}
disable() {}
},
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(() => mocks.manager),
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {
constructor(...args: unknown[]) {
mocks.jsonArguments = args;
}
open() {
mocks.jsonOpen();
const callback = mocks.jsonArguments[3] as (keep?: string, result?: string) => Promise<void>;
void callback(undefined, mocks.jsonResult);
}
},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {
constructor(...args: unknown[]) {
mocks.conflictArguments = args;
}
open() {
mocks.conflictOpen();
}
async waitForResult() {
return mocks.conflictResult;
}
},
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores", () => ({
pluginScanningCount: mocks.scanCount,
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/services/lib/logUtils", () => ({
createInstanceLogFunction: vi.fn(() => mocks.log),
}));
import { createCustomisationSyncObsidianDependencies } from "./customisationSyncObsidianAdapter.ts";
function createHost() {
const listResult = { files: [".obsidian/app.json"], folders: [".obsidian/plugins"] };
const list = vi.fn(async () => listResult);
const notices = { show: vi.fn(), hide: vi.fn() };
const firstSettings = { marker: "first" };
const firstDatabase = { marker: "first-db" };
const storageAccess = { marker: "storage" };
const path = { marker: "path" };
const services = {
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
appLifecycle: {
askRestart: vi.fn(),
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
},
context: {
app: { vault: { adapter: { list } } },
notices,
},
database: { localDatabase: firstDatabase },
path,
replication: { replicateUserInitiated: vi.fn(async () => undefined) },
setting: {
settings: firstSettings,
applyPartial: vi.fn(async () => undefined),
getDeviceAndVaultName: vi.fn(() => "device-a"),
saveSettingData: vi.fn(async () => undefined),
setDeviceAndVaultName: vi.fn(),
},
UI: { confirm: { askString: vi.fn(async () => "device-b") } },
};
const host = { services, serviceModules: { storageAccess } };
const getUIControl = vi.fn(() => undefined);
const ownsLocalDocument = vi.fn(() => true);
const ownsLocalFile = vi.fn(() => true);
const dependencies = createCustomisationSyncObsidianDependencies(host as never, {
getUIControl,
ownsLocalDocument,
ownsLocalFile,
});
return {
dependencies,
firstDatabase,
firstSettings,
getUIControl,
host,
list,
listResult,
notices,
ownsLocalDocument,
ownsLocalFile,
path,
services,
storageAccess,
};
}
describe("Customisation Sync Obsidian adapter", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.conflictResult = "B";
mocks.jsonResult = "merged";
mocks.scanCount.value = 0;
});
it("provides live settings and database projections with explicit host telemetry", async () => {
const fixture = createHost();
expect(fixture.dependencies.getSettings()).toBe(fixture.firstSettings);
expect(fixture.dependencies.getLocalDatabase()).toBe(fixture.firstDatabase);
expect(fixture.dependencies.storageAccess).toBe(fixture.storageAccess);
expect(fixture.dependencies.path).toBe(fixture.path);
const replacementSettings = { marker: "replacement" };
const replacementDatabase = { marker: "replacement-db" };
fixture.services.setting.settings = replacementSettings;
fixture.services.database.localDatabase = replacementDatabase;
expect(fixture.dependencies.getSettings()).toBe(replacementSettings);
expect(fixture.dependencies.getLocalDatabase()).toBe(replacementDatabase);
fixture.dependencies.publishScanCount(3);
expect(mocks.scanCount.value).toBe(3);
expect(fixture.dependencies.ownsLocalFile(".obsidian/app.json" as FilePath)).toBe(true);
expect(fixture.dependencies.ownsLocalDocument("ix:device/app.json" as FilePathWithPrefix)).toBe(true);
const process = vi.fn(async () => undefined);
fixture.dependencies.createPeriodicProcessor(process);
expect(mocks.periodicArguments).toEqual([fixture.host, process]);
await expect(fixture.dependencies.listFiles(".obsidian")).resolves.toBe(fixture.listResult);
expect(fixture.list).toHaveBeenCalledWith(".obsidian");
});
it("owns Obsidian conflict dialogue construction", async () => {
const { dependencies, host } = createHost();
const files = [{ path: "local.json" }, { path: "remote.json" }] as never;
const apply = vi.fn(async () => true);
await expect(dependencies.resolveJsonConflict("app.json" as FilePath, files, "device-b", apply)).resolves.toBe(
true
);
expect(mocks.jsonOpen).toHaveBeenCalledOnce();
expect(mocks.jsonArguments.slice(0, 3)).toEqual([host.services.context.app, "app.json", files]);
expect(mocks.jsonArguments.slice(4)).toEqual([
"Local",
"device-b",
"B",
true,
true,
"Difference between local and remote",
]);
expect(apply).toHaveBeenCalledWith("merged");
await expect(dependencies.selectTextFile("app.css" as FilePath, {} as never, "device-b")).resolves.toBe("B");
expect(mocks.conflictOpen).toHaveBeenCalledOnce();
mocks.conflictResult = CANCELLED;
await expect(dependencies.selectTextFile("app.css" as FilePath, {} as never, "device-b")).resolves.toBe(false);
});
it("owns plug-in reload, Notice, and fallback device-name effects", async () => {
const { dependencies, notices } = createHost();
let click: (() => void) | undefined;
const fragment = {
createSpan: (_options: unknown, build: (span: unknown) => void) => {
const span = {
appendText: vi.fn(),
appendChild: vi.fn(),
createEl: (_tag: string, _options: unknown, buildAnchor: (anchor: unknown) => void) => {
const anchor = {
text: "",
addEventListener: (_event: string, callback: () => void) => {
click = callback;
},
};
buildAnchor(anchor);
return anchor;
},
};
build(span);
},
};
vi.stubGlobal("createFragment", (build: (value: typeof fragment) => void) => {
build(fragment);
return fragment;
});
const openDialog = vi.fn();
await dependencies.reloadPlugin(".obsidian", "example");
expect(mocks.manager.unloadPlugin).toHaveBeenCalledWith("example");
expect(mocks.manager.loadPlugin).toHaveBeenCalledWith("example");
expect(mocks.log).toHaveBeenNthCalledWith(
1,
"Unloading plugin: Example",
LOG_LEVEL_NOTICE,
"plugin-reload-example"
);
dependencies.showConfigurationNotice(openDialog);
expect(notices.show).toHaveBeenCalledWith("config-sync:updated-configuration", fragment, {
durationMs: 20_000,
});
click?.();
expect(openDialog).toHaveBeenCalledOnce();
dependencies.hideConfigurationNotice();
expect(notices.hide).toHaveBeenCalledWith("config-sync:updated-configuration");
expect(dependencies.getFallbackDeviceName()).toMatch(/^macos[a-z0-9]{4}$/);
});
});
@@ -0,0 +1,170 @@
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, type LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fireAndForget, getFileRegExp, sendSignal } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
hiddenFilesEventCount,
hiddenFilesProcessingCount,
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
import type {
HiddenFileSyncContextDependencies,
HiddenFileSyncJsonResolution,
HiddenFileSyncProgress,
} from "@/features/HiddenFileSync/hiddenFileSyncContext.ts";
import { MARK_DONE } from "@/modules/features/ModuleLog.ts";
import type { LiveSyncCore } from "@/main.ts";
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
export type HiddenFileSyncObsidianPolicies = Pick<HiddenFileSyncContextDependencies, "ownsLocalFile">;
/** Adapt the Obsidian host to the narrow capabilities used by Hidden File Sync. */
export function createHiddenFileSyncObsidianDependencies(
host: LiveSyncCore,
policies: HiddenFileSyncObsidianPolicies
): HiddenFileSyncContextDependencies {
const { services, serviceModules } = host;
const app = services.context.app;
const log = createInstanceLogFunction("HiddenFileSyncContext", services.API);
let noticeIndex = 0;
const activeConflictDialogs = new Map<string, symbol>();
const createProgress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE): HiddenFileSyncProgress => {
const key = `keepalive-progress-${noticeIndex++}`;
return {
log: (message) => log(prefix + message, level, key),
once: (message) => log(prefix + message, level),
done: (message: string = "Done") => log(prefix + message + MARK_DONE, level, key),
};
};
const resolveJsonConflict: HiddenFileSyncContextDependencies["resolveJsonConflict"] = (path, docs, apply) =>
new Promise<boolean>((resolve, reject) => {
// Replacing a dialogue for the same path must close the old instance.
const conflictPath = path;
const token = Symbol(conflictPath);
let settled = false;
sendSignal(`cancel-internal-conflict:${conflictPath}`);
activeConflictDialogs.set(conflictPath, token);
const modal = new JsonResolveModal(app, path, docs, async (keepRevision, mergedText) => {
if (settled) return;
settled = true;
const resolution: HiddenFileSyncJsonResolution = { keepRevision, mergedText };
try {
resolve(await apply(resolution));
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
} finally {
if (activeConflictDialogs.get(conflictPath) === token) {
activeConflictDialogs.delete(conflictPath);
}
}
});
modal.open();
});
const showConfigurationChangeNotice = (updatedFolders: readonly string[]) => {
const noticeGroups = services.context.noticeGroups;
let hasNoticeItems = false;
try {
const pluginManager = getObsidianCommunityPluginManager(app);
const enabledPluginManifests = pluginManager.manifests.filter((manifest) =>
pluginManager.enabledPlugins.has(manifest.id)
);
const modifiedManifests = enabledPluginManifests.filter((manifest) =>
updatedFolders.includes(manifest.dir ?? "")
);
for (const manifest of modifiedManifests) {
const pluginId = manifest.id;
const pluginName = manifest.name;
const itemKey = `plugin:${pluginId}`;
noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, itemKey, {
message: `Files in ${pluginName} were updated.`,
action: {
label: `Reload ${pluginName}`,
onSelect: () => {
fireAndForget(async () => {
const logKey = `plugin-reload-${pluginId}`;
log(`Unloading plugin: ${pluginName}`, LOG_LEVEL_NOTICE, logKey);
await pluginManager.unloadPlugin(pluginId);
await pluginManager.loadPlugin(pluginId);
log(`Plugin reloaded: ${pluginName}`, LOG_LEVEL_NOTICE, logKey);
noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, itemKey);
});
},
},
});
hasNoticeItems = true;
}
} catch (error) {
log("Error on checking plugin status.");
log(error, LOG_LEVEL_VERBOSE);
}
if (updatedFolders.includes(services.API.getSystemConfigDir())) {
if (!services.appLifecycle.isReloadingScheduled()) {
noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, "restart", {
message: "Other Obsidian settings files were updated.",
action: {
label: "Schedule an Obsidian restart",
onSelect: () => {
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 });
}
};
return {
getSettings: () => services.setting.settings,
getLocalDatabase: () => services.database.localDatabase,
getKeyValueDatabase: () => services.keyValueDB.kvDB,
storageAccess: serviceModules.storageAccess,
databaseFileAccess: serviceModules.databaseFileAccess,
path: services.path,
log,
createProgress,
createPeriodicProcessor: (process) => new PeriodicProcessor(host, process),
isReady: () => services.appLifecycle.isReady(),
isSuspended: () => services.appLifecycle.isSuspended(),
isDatabaseReady: () => services.database.isDatabaseReady(),
isIgnoredByIgnoreFile: async (path) => await services.vault.isIgnoredByIgnoreFile(path),
getConfigDir: () => services.API.getSystemConfigDir(),
getRootPath: () => app.vault.getRoot().path,
listFiles: async (path) => await app.vault.adapter.list(path),
getFileRegExp: (key) => getFileRegExp(services.setting.settings, key),
applySettings: async (partial, saveImmediately) =>
await services.setting.applyPartial(partial, saveImmediately),
setSyncInternalFilesEnabled: (enabled) => {
services.setting.settings.syncInternalFiles = enabled;
},
resolveJsonConflict,
showConfigurationChangeNotice,
hideConfigurationChangeNotice: () => {
services.context.noticeGroups.hide(HIDDEN_FILE_NOTICE_GROUP);
},
closeJsonConflictDialogs: () => {
for (const path of activeConflictDialogs.keys()) {
sendSignal(`cancel-internal-conflict:${path}`);
}
activeConflictDialogs.clear();
},
publishActivity: (eventCount, processingCount) => {
hiddenFilesEventCount.value = eventCount;
hiddenFilesProcessingCount.value = processingCount;
},
ownsLocalFile: (path) => policies.ownsLocalFile(path),
};
}
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
hiddenFilesEventCount,
hiddenFilesProcessingCount,
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
const mocks = vi.hoisted(() => ({
log: vi.fn(),
sendSignal: vi.fn(),
getFileRegExp: vi.fn(() => []),
openModal: vi.fn(),
modal: {
resolve: undefined as ((keepRevision?: string, mergedText?: string) => Promise<void>) | undefined,
},
pluginManager: {
manifests: [
{ id: "alpha", name: "Alpha", dir: ".obsidian/plugins/alpha" },
{ id: "beta", name: "Beta", dir: ".obsidian/plugins/beta" },
],
enabledPlugins: new Set(["alpha", "beta"]),
unloadPlugin: vi.fn(async () => undefined),
loadPlugin: vi.fn(async () => undefined),
},
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", () => ({
fireAndForget: (operation: () => Promise<unknown>) => void operation(),
getFileRegExp: mocks.getFileRegExp,
sendSignal: mocks.sendSignal,
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/services/lib/logUtils", () => ({
createInstanceLogFunction: () => mocks.log,
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {
constructor(
_app: unknown,
_path: unknown,
_docs: unknown,
callback: (keepRevision?: string, mergedText?: string) => Promise<void>
) {
mocks.modal.resolve = callback;
}
open() {
mocks.openModal();
}
},
}));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "<done>" }));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: () => mocks.pluginManager,
}));
import { createHiddenFileSyncObsidianDependencies } from "./hiddenFileSyncObsidianAdapter.ts";
function createFixture() {
const noticeGroups = {
setItem: vi.fn(),
finish: vi.fn(() => true),
removeItem: vi.fn(() => true),
hide: vi.fn(() => true),
};
const scheduleRestart = vi.fn();
const settings = {
syncInternalFiles: true,
syncInternalFileOverwritePatterns: "",
syncInternalFilesIgnorePatterns: "",
syncInternalFilesTargetPatterns: "",
};
const app = {
vault: {
getRoot: vi.fn(() => ({ path: "" })),
adapter: { list: vi.fn(async () => ({ files: [], folders: [] })) },
},
};
const host = {
services: {
context: { app, noticeGroups },
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
appLifecycle: {
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
isReloadingScheduled: vi.fn(() => false),
scheduleRestart,
},
database: { localDatabase: {}, isDatabaseReady: vi.fn(() => true) },
keyValueDB: { kvDB: {} },
path: {},
setting: { settings, applyPartial: vi.fn(async () => undefined) },
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
},
serviceModules: {
storageAccess: {},
databaseFileAccess: {},
},
};
const ownsLocalFile = vi.fn(() => true);
const dependencies = createHiddenFileSyncObsidianDependencies(host as never, { ownsLocalFile });
return { dependencies, noticeGroups, scheduleRestart, settings };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.modal.resolve = undefined;
hiddenFilesEventCount.value = 0;
hiddenFilesProcessingCount.value = 0;
});
describe("Hidden File Sync Obsidian adapter", () => {
it("owns grouped plug-in reload and application restart Notices", async () => {
const { dependencies, noticeGroups, scheduleRestart } = createFixture();
dependencies.showConfigurationChangeNotice([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]);
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.finish).toHaveBeenCalledWith("hidden-file-changes", { durationMs: 20_000 });
const reload = (noticeGroups.setItem.mock.calls[0]?.[2] as { action: { onSelect(): void } }).action.onSelect;
reload();
await vi.waitFor(() => {
expect(mocks.pluginManager.unloadPlugin).toHaveBeenCalledWith("alpha");
expect(mocks.pluginManager.loadPlugin).toHaveBeenCalledWith("alpha");
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "plugin:alpha");
});
const restart = (noticeGroups.setItem.mock.calls[2]?.[2] as { action: { onSelect(): void } }).action.onSelect;
restart();
expect(scheduleRestart).toHaveBeenCalledOnce();
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart");
dependencies.hideConfigurationChangeNotice();
expect(noticeGroups.hide).toHaveBeenCalledWith("hidden-file-changes");
});
it("owns the conflict dialogue, progress presentation, and compatibility activity publication", async () => {
const { dependencies } = createFixture();
const docs = [{ path: "i:.obsidian/app.json" }, { path: "i:.obsidian/app.json" }] as never;
const apply = vi.fn(async () => true);
const resolution = dependencies.resolveJsonConflict(".obsidian/app.json" as never, docs, apply);
expect(mocks.sendSignal).toHaveBeenCalledWith("cancel-internal-conflict:.obsidian/app.json");
expect(mocks.openModal).toHaveBeenCalledOnce();
await mocks.modal.resolve?.("2-selected", "merged");
await expect(resolution).resolves.toBe(true);
expect(apply).toHaveBeenCalledWith({ keepRevision: "2-selected", mergedText: "merged" });
const progress = dependencies.createProgress("Prefix: ", LOG_LEVEL_NOTICE);
progress.log("Working");
progress.once("Once");
progress.done();
expect(mocks.log).toHaveBeenNthCalledWith(1, "Prefix: Working", LOG_LEVEL_NOTICE, "keepalive-progress-0");
expect(mocks.log).toHaveBeenNthCalledWith(2, "Prefix: Once", LOG_LEVEL_NOTICE);
expect(mocks.log).toHaveBeenNthCalledWith(3, "Prefix: Done<done>", LOG_LEVEL_NOTICE, "keepalive-progress-0");
dependencies.publishActivity(3, 1);
expect(hiddenFilesEventCount.value).toBe(3);
expect(hiddenFilesProcessingCount.value).toBe(1);
});
it("closes each active conflict dialogue with the path used by the modal", async () => {
const { dependencies } = createFixture();
const docs = [{ path: "i:.obsidian/app.json" }, { path: "i:.obsidian/app.json" }] as never;
const apply = vi.fn(async () => false);
const resolution = dependencies.resolveJsonConflict(".obsidian/app.json" as never, docs, apply);
dependencies.closeJsonConflictDialogs();
expect(mocks.sendSignal).toHaveBeenNthCalledWith(1, "cancel-internal-conflict:.obsidian/app.json");
expect(mocks.sendSignal).toHaveBeenNthCalledWith(2, "cancel-internal-conflict:.obsidian/app.json");
await mocks.modal.resolve?.();
await expect(resolution).resolves.toBe(false);
expect(apply).toHaveBeenCalledWith({ keepRevision: undefined, mergedText: undefined });
});
});
@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
describe("Optional File Sync ownership boundary", () => {
it("keeps the concrete runtimes behind the composition feature", () => {
const mainSource = read("../main.ts");
const featureSource = read("./useOptionalFileSync.ts");
const customisationAdapterSource = read("./customisationSyncObsidianAdapter.ts");
const hiddenFileAdapterSource = read("./hiddenFileSyncObsidianAdapter.ts");
const customisationSource = read("../features/ConfigSync/customisationSyncContext.ts");
const hiddenSource = read("../features/HiddenFileSync/hiddenFileSyncContext.ts");
expect(mainSource).not.toContain("new CustomisationSyncContext");
expect(mainSource).not.toContain("new HiddenFileSyncContext");
expect(mainSource).not.toContain("optionalFileSync.testing");
expect(featureSource).toContain("new CustomisationSyncContext");
expect(featureSource).toContain("new HiddenFileSyncContext");
expect(customisationSource).not.toContain("onBindFunction(");
expect(hiddenSource).not.toContain("onBindFunction(");
expect(customisationSource).not.toContain("LiveSyncCommands");
expect(hiddenSource).not.toContain("LiveSyncCommands");
expect(customisationSource).not.toContain("extends LiveSyncContext");
expect(customisationSource).not.toMatch(/from ["'][^"']*LiveSyncContext(?:\.ts)?["']/);
expect(customisationSource).not.toMatch(/from ["'][^"']*main(?:\.ts)?["']/);
expect(customisationSource).not.toMatch(/\bthis\.(?:app|core|services)\b/);
expect(customisationSource).not.toContain("JsonResolveModal");
expect(customisationSource).not.toContain("ConflictResolveModal");
expect(customisationSource).not.toContain("getObsidianCommunityPluginManager");
expect(customisationSource).not.toContain("pluginScanningCount");
expect(featureSource).toContain("createCustomisationSyncObsidianDependencies");
expect(customisationAdapterSource).toContain("JsonResolveModal");
expect(customisationAdapterSource).toContain("ConflictResolveModal");
expect(hiddenSource).not.toContain("extends LiveSyncContext");
expect(hiddenSource).not.toMatch(/from ["'][^"']*LiveSyncContext(?:\.ts)?["']/);
expect(hiddenSource).not.toMatch(/from ["'][^"']*main(?:\.ts)?["']/);
expect(hiddenSource).not.toMatch(/\bthis\.(?:app|core|services)\b/);
expect(hiddenSource).not.toContain("JsonResolveModal");
expect(hiddenSource).not.toContain("getObsidianCommunityPluginManager");
expect(hiddenSource).not.toContain("hiddenFilesEventCount");
expect(hiddenSource).not.toContain("hiddenFilesProcessingCount");
expect(featureSource).toContain("createHiddenFileSyncObsidianDependencies");
expect(hiddenFileAdapterSource).toContain("JsonResolveModal");
expect(hiddenFileAdapterSource).toContain("getObsidianCommunityPluginManager");
expect(hiddenFileAdapterSource).toContain("hiddenFilesEventCount");
expect(hiddenFileAdapterSource).toContain("hiddenFilesProcessingCount");
});
it("removes constructor-name add-on lookup from the core", () => {
const coreSource = read("../LiveSyncBaseCore.ts");
expect(coreSource).not.toContain("getAddOn");
expect(coreSource).not.toContain("addon.constructor.name");
});
it("keeps real-Obsidian tests on the explicit feature test surface", () => {
const customisationE2e = read("../../test/e2e-obsidian/scripts/customisation-sync.ts");
const hiddenE2e = read("../../test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts");
const setupE2e = read("../../test/e2e-obsidian/scripts/setup-uri-workflow.ts");
for (const source of [customisationE2e, hiddenE2e, setupE2e]) {
expect(source).not.toContain("getAddOn(");
}
expect(customisationE2e).toContain("optionalFileSync.testing.customisationSync");
expect(hiddenE2e).toContain("optionalFileSync.testing.hiddenFileSync");
expect(setupE2e).toContain("optionalFileSync.testing.hiddenFileSync");
});
});
@@ -0,0 +1,130 @@
import {
MODE_AUTOMATIC,
MODE_PAUSED,
MODE_SELECTIVE,
MODE_SHINY,
type PluginSyncSettingEntry,
type SYNC_MODE,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
getCustomisationSyncFileCategory,
getCustomisationSyncSettingKey,
getCustomisationSyncSettingKeyFromDocumentPath,
type CustomisationSyncFileCategory,
type CustomisationSyncPathOptions,
} from "@/features/ConfigSync/customisationSyncPaths.ts";
export type OptionalFileSyncOwner = "customisation" | "hidden-file" | "none";
export type OptionalFileSyncRoutingReason =
| "customisation-selective"
| "customisation-flagged-selective"
| "customisation-paused"
| "customisation-not-ready"
| "hidden-file-automatic"
| "hidden-file-path"
| "hidden-file-disabled"
| "hidden-file-not-ready"
| "hidden-file-filtered"
| "features-disabled"
| "unsupported-path";
export type OptionalFileSyncRoutingDecision = {
owner: OptionalFileSyncOwner;
reason: OptionalFileSyncRoutingReason;
category: CustomisationSyncFileCategory;
settingKey?: string;
mode?: SYNC_MODE;
};
export type OptionalFileSyncOwnerSelectionInput = CustomisationSyncPathOptions & {
path: string;
customisationEnabled: boolean;
hiddenFileEnabled: boolean;
pluginSyncExtendedSetting: Readonly<Record<string, PluginSyncSettingEntry>>;
};
export type OptionalFileSyncRoutingInput = OptionalFileSyncOwnerSelectionInput & {
customisationReady: boolean;
hiddenFileReady: boolean;
hiddenFileEligible: boolean;
};
export type CustomisationSyncDocumentOwnershipInput = {
documentPath: FilePathWithPrefix;
customisationEnabled: boolean;
pluginSyncExtendedSetting: Readonly<Record<string, PluginSyncSettingEntry>>;
};
function isHiddenFileSyncPath(path: string): boolean {
return path.startsWith(".") && !path.startsWith(".trash");
}
/** Select the sole local writer from persisted feature settings. */
export function selectOptionalFileSyncOwner(
input: OptionalFileSyncOwnerSelectionInput
): OptionalFileSyncRoutingDecision {
const pathOptions: CustomisationSyncPathOptions = input;
const settingKey = getCustomisationSyncSettingKey(input.path, pathOptions);
const category = settingKey ? getCustomisationSyncFileCategory(input.path, pathOptions) : "";
if (!input.customisationEnabled && !input.hiddenFileEnabled) {
return { owner: "none", reason: "features-disabled", category, settingKey };
}
if (settingKey && input.customisationEnabled) {
const mode = input.pluginSyncExtendedSetting[settingKey]?.mode ?? MODE_SELECTIVE;
if (mode == MODE_SELECTIVE) {
return { owner: "customisation", reason: "customisation-selective", category, settingKey, mode };
}
if (mode == MODE_SHINY) {
return {
owner: "customisation",
reason: "customisation-flagged-selective",
category,
settingKey,
mode,
};
}
if (mode == MODE_PAUSED) {
return { owner: "none", reason: "customisation-paused", category, settingKey, mode };
}
if (mode == MODE_AUTOMATIC) {
return input.hiddenFileEnabled
? { owner: "hidden-file", reason: "hidden-file-automatic", category, settingKey, mode }
: { owner: "none", reason: "hidden-file-disabled", category, settingKey, mode };
}
}
if (input.hiddenFileEnabled && isHiddenFileSyncPath(input.path)) {
return { owner: "hidden-file", reason: "hidden-file-path", category, settingKey };
}
return { owner: "none", reason: "unsupported-path", category, settingKey };
}
/** Apply transient readiness and asynchronous Hidden File Sync eligibility to the selected owner. */
export function routeOptionalFileSyncPath(input: OptionalFileSyncRoutingInput): OptionalFileSyncRoutingDecision {
const selected = selectOptionalFileSyncOwner(input);
if (selected.owner == "customisation" && !input.customisationReady) {
return { ...selected, owner: "none", reason: "customisation-not-ready" };
}
if (selected.owner == "hidden-file" && !input.hiddenFileReady) {
return { ...selected, owner: "none", reason: "hidden-file-not-ready" };
}
if (selected.owner == "hidden-file" && !input.hiddenFileEligible) {
return { ...selected, owner: "none", reason: "hidden-file-filtered" };
}
return selected;
}
/** Decide whether a local scan may mutate an existing Customisation Sync document. */
export function isCustomisationSyncDocumentLocallyOwned(input: CustomisationSyncDocumentOwnershipInput): boolean {
if (!input.customisationEnabled) return false;
const settingKey = getCustomisationSyncSettingKeyFromDocumentPath(input.documentPath);
if (!settingKey) return false;
const mode = input.pluginSyncExtendedSetting[settingKey]?.mode ?? MODE_SELECTIVE;
return mode == MODE_SELECTIVE || mode == MODE_SHINY;
}
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import {
MODE_AUTOMATIC,
MODE_PAUSED,
MODE_SELECTIVE,
MODE_SHINY,
type PluginSyncSettingEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isCustomisationSyncDocumentLocallyOwned, routeOptionalFileSyncPath } from "./optionalFileSyncRouting.ts";
const PATH = ".obsidian/plugins/example/data.json";
function route(
mode: PluginSyncSettingEntry["mode"] | undefined,
overrides: Partial<Parameters<typeof routeOptionalFileSyncPath>[0]> = {}
) {
const pluginSyncExtendedSetting: Record<string, PluginSyncSettingEntry> =
mode === undefined
? {}
: {
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode,
files: [],
},
};
return routeOptionalFileSyncPath({
path: PATH,
configDir: ".obsidian",
useV2: true,
usePluginEtc: true,
customisationEnabled: true,
customisationReady: true,
hiddenFileEnabled: true,
hiddenFileReady: true,
hiddenFileEligible: true,
pluginSyncExtendedSetting,
...overrides,
});
}
describe("optional-file local-path routing policy", () => {
it.each([
["default Selective", undefined, "customisation", "customisation-selective"],
["persisted Selective", MODE_SELECTIVE, "customisation", "customisation-selective"],
["Flagged Selective", MODE_SHINY, "customisation", "customisation-flagged-selective"],
["Automatic", MODE_AUTOMATIC, "hidden-file", "hidden-file-automatic"],
["Ignore", MODE_PAUSED, "none", "customisation-paused"],
] as const)("assigns %s to one owner", (_label, mode, owner, reason) => {
expect(route(mode)).toMatchObject({
owner,
reason,
category: "PLUGIN_DATA",
settingKey: "PLUGIN_DATA/example",
});
});
it("uses the persisted setting key even when its file list is empty", () => {
expect(route(MODE_AUTOMATIC)).toMatchObject({
owner: "hidden-file",
settingKey: "PLUGIN_DATA/example",
mode: MODE_AUTOMATIC,
});
});
it("does not apply Hidden File Sync filters to a Customisation Sync owner", () => {
expect(route(MODE_SELECTIVE, { hiddenFileEligible: false })).toMatchObject({
owner: "customisation",
reason: "customisation-selective",
});
});
it("applies readiness and Hidden File Sync eligibility after selecting the owner", () => {
expect(route(MODE_SELECTIVE, { customisationReady: false })).toMatchObject({
owner: "none",
reason: "customisation-not-ready",
});
expect(route(MODE_AUTOMATIC, { hiddenFileReady: false })).toMatchObject({
owner: "none",
reason: "hidden-file-not-ready",
});
expect(route(MODE_AUTOMATIC, { hiddenFileEligible: false })).toMatchObject({
owner: "none",
reason: "hidden-file-filtered",
});
});
it("does not fall back to Customisation Sync when Automatic mode has no Hidden File Sync owner", () => {
expect(route(MODE_AUTOMATIC, { hiddenFileEnabled: false })).toMatchObject({
owner: "none",
reason: "hidden-file-disabled",
});
});
it("routes recognised files to Hidden File Sync when Customisation Sync is disabled", () => {
expect(route(undefined, { customisationEnabled: false })).toMatchObject({
owner: "hidden-file",
reason: "hidden-file-path",
});
});
it("routes other eligible hidden paths only to Hidden File Sync", () => {
expect(route(undefined, { path: ".obsidian/workspace" })).toMatchObject({
owner: "hidden-file",
reason: "hidden-file-path",
category: "",
});
expect(route(undefined, { path: ".trash/workspace.json" })).toMatchObject({
owner: "none",
reason: "unsupported-path",
});
expect(route(undefined, { path: "notes/workspace.json" })).toMatchObject({
owner: "none",
reason: "unsupported-path",
});
});
it("returns no owner when both optional-file features are disabled", () => {
expect(
route(undefined, {
customisationEnabled: false,
hiddenFileEnabled: false,
})
).toMatchObject({ owner: "none", reason: "features-disabled" });
});
});
describe("Customisation Sync scan-document ownership", () => {
it.each([
["default Selective", undefined, true],
["persisted Selective", MODE_SELECTIVE, true],
["Flagged Selective", MODE_SHINY, true],
["Automatic", MODE_AUTOMATIC, false],
["Ignore", MODE_PAUSED, false],
] as const)("allows mutation for %s=%s", (_label, mode, expected) => {
const pluginSyncExtendedSetting: Record<string, PluginSyncSettingEntry> =
mode === undefined
? {}
: {
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode,
files: [],
},
};
expect(
isCustomisationSyncDocumentLocallyOwned({
documentPath: "ix:device-a/PLUGIN_DATA/example.md" as never,
customisationEnabled: true,
pluginSyncExtendedSetting,
})
).toBe(expected);
});
it("disallows scan mutation while Customisation Sync is disabled", () => {
expect(
isCustomisationSyncDocumentLocallyOwned({
documentPath: "ix:device-a/PLUGIN_DATA/example.md" as never,
customisationEnabled: false,
pluginSyncExtendedSetting: {},
})
).toBe(false);
});
});
@@ -0,0 +1,149 @@
import type { App } from "@/deps.ts";
import { addIcon } from "@/deps.ts";
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type {
CustomisationSyncDialogView,
CustomisationSyncUIControl,
} from "@/features/ConfigSync/customisationSyncView.ts";
import type { HiddenFileSyncInitialisationView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
import { PluginDialogModal } from "@/features/ConfigSync/PluginDialogModal.ts";
import { $msg } from "@/common/translation";
/** Services needed to compose the Obsidian-owned Customisation Sync UI. */
export type CustomisationSyncUIHost = NecessaryServices<"API" | "appLifecycle", never>;
/** The small modal surface owned by this composition feature. */
export interface CustomisationSyncDialog {
open(): void;
close(): void;
isOpened?(): boolean;
isOpen?(): boolean;
}
/** Replaceable modal construction seam used by focused interaction tests. */
export type CustomisationSyncDialogFactory = (
app: App,
customisationSync: CustomisationSyncDialogView,
hiddenFileSync: HiddenFileSyncInitialisationView
) => CustomisationSyncDialog;
type RibbonElement = {
addClass?: (name: string) => unknown;
remove?: () => unknown;
};
const CUSTOM_SYNC_ICON = `<g transform="rotate(-90 75 218)" fill="currentColor" fill-rule="evenodd">
<path d="m272 166-9.38 9.38 9.38 9.38 9.38-9.38c1.96-1.93 5.11-1.9 7.03 0.058 1.91 1.94 1.91 5.04 0 6.98l-9.38 9.38 5.86 5.86-11.7 11.7c-8.34 8.35-21.4 9.68-31.3 3.19l-3.84 3.98c-8.45 8.7-20.1 13.6-32.2 13.6h-5.55v-9.95h5.55c9.43-0.0182 18.5-3.84 25-10.6l3.95-4.09c-6.54-9.86-5.23-23 3.14-31.3l11.7-11.7 5.86 5.86 9.38-9.38c1.96-1.93 5.11-1.9 7.03 0.0564 1.91 1.93 1.91 5.04 2e-3 6.98z"/>
</g>`;
function defaultDialogFactory(
app: App,
customisationSync: CustomisationSyncDialogView,
hiddenFileSync: HiddenFileSyncInitialisationView
): CustomisationSyncDialog {
return new PluginDialogModal(app, customisationSync, hiddenFileSync);
}
function readDialogOpenState(dialog: CustomisationSyncDialog, fallback: boolean): boolean {
const isOpened = dialog.isOpened?.();
if (typeof isOpened === "boolean") return isOpened;
const isOpen = dialog.isOpen?.();
if (typeof isOpen === "boolean") return isOpen;
return fallback;
}
/**
* Compose the Obsidian-owned Customisation Sync command, ribbon, and modal.
*
* Registration happens from `onLoaded`: the legacy add-on registered its UI
* from `onload`, which is invoked after that lifecycle phase. Settings have
* already been loaded by `onLoaded`, while command availability is still
* evaluated lazily from the focused view.
*/
export function useCustomisationSyncUI(
host: CustomisationSyncUIHost,
app: App,
customisationSync: CustomisationSyncDialogView,
hiddenFileSync: HiddenFileSyncInitialisationView,
createDialog: CustomisationSyncDialogFactory = defaultDialogFactory
): CustomisationSyncUIControl {
const { API, appLifecycle } = host.services;
let dialog: CustomisationSyncDialog | undefined;
let dialogOpened = false;
let ribbonElement: RibbonElement | undefined;
let requestOpenDisposer: (() => void) | undefined;
let loadedDisposer: (() => void) | undefined;
let resumedDisposer: (() => void) | undefined;
let disposed = false;
let registered = false;
const open = () => {
if (disposed || !customisationSync.isEnabled()) return;
if (!dialog) {
dialog = createDialog(app, customisationSync, hiddenFileSync);
}
dialog.open();
dialogOpened = true;
};
const close = () => {
const currentDialog = dialog;
dialog = undefined;
dialogOpened = false;
currentDialog?.close();
};
const isOpen = () => {
if (!dialog) return false;
return readDialogOpenState(dialog, dialogOpened);
};
const updateRibbonVisibility = () => {
if (typeof activeDocument === "undefined") return true;
const element = activeDocument.querySelector<HTMLElement>(".livesync-ribbon-showcustom");
element?.toggleClass("sls-hidden", !customisationSync.isEnabled());
return true;
};
const registerUI = () => {
if (disposed || registered) return Promise.resolve(true);
registered = true;
addIcon("custom-sync", CUSTOM_SYNC_ICON);
API.addCommand({
id: "livesync-plugin-dialog-ex",
name: "Show customization sync dialog",
checkCallback: (checking) => {
if (!customisationSync.isEnabled()) return false;
if (!checking) open();
return true;
},
});
ribbonElement = API.addRibbonIcon("custom-sync", $msg("cmdConfigSync.showCustomizationSync"), () => open());
ribbonElement?.addClass?.("livesync-ribbon-showcustom");
requestOpenDisposer = eventHub.onEvent(EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, () => open());
return Promise.resolve(true);
};
loadedDisposer = appLifecycle.onLoaded.addHandler(registerUI);
resumedDisposer = appLifecycle.onResumed.addHandler(() => Promise.resolve(updateRibbonVisibility()));
appLifecycle.onUnload.addHandler(() => {
disposed = true;
close();
requestOpenDisposer?.();
requestOpenDisposer = undefined;
ribbonElement?.remove?.();
ribbonElement = undefined;
loadedDisposer?.();
loadedDisposer = undefined;
resumedDisposer?.();
resumedDisposer = undefined;
return Promise.resolve(true);
});
return Object.freeze({ open, close, isOpen });
}
@@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "request-open-plugin-sync-dialog",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((key: string) => key),
}));
vi.mock("@/features/ConfigSync/PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
import { addIcon } from "@/deps.ts";
import { eventHub } from "@/common/events.ts";
import {
type CustomisationSyncDialog,
type CustomisationSyncDialogFactory,
useCustomisationSyncUI,
} from "./useCustomisationSyncUI.ts";
type Handler = () => unknown;
function createFixture(enabled = true) {
let settingEnabled = enabled;
let loadedHandler: Handler | undefined;
let resumedHandler: Handler | undefined;
let unloadHandler: Handler | undefined;
const loadedDisposer = vi.fn();
const resumedDisposer = vi.fn();
const eventDisposer = vi.fn();
const ribbonElement = {
addClass: vi.fn(),
remove: vi.fn(),
};
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const ribbonCallbacks: Array<() => unknown> = [];
const host = {
services: {
API: {
addCommand: vi.fn((command) => {
commands.push(command);
return command;
}),
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => unknown) => {
ribbonCallbacks.push(callback);
return ribbonElement;
}),
},
appLifecycle: {
onLoaded: {
addHandler: vi.fn((handler: Handler) => {
loadedHandler = handler;
return loadedDisposer;
}),
},
onResumed: {
addHandler: vi.fn((handler: Handler) => {
resumedHandler = handler;
return resumedDisposer;
}),
},
onUnload: {
addHandler: vi.fn((handler: Handler) => {
unloadHandler = handler;
return vi.fn();
}),
},
},
},
} as any;
const customisationSync = {
isEnabled: vi.fn(() => settingEnabled),
} as any;
const hiddenFileSync = {} as any;
const modal: CustomisationSyncDialog = {
open: vi.fn(),
close: vi.fn(),
isOpened: vi.fn(() => true),
};
const createDialog = vi.fn<CustomisationSyncDialogFactory>(() => modal);
return {
host,
customisationSync,
hiddenFileSync,
modal,
createDialog,
commands,
ribbonCallbacks,
ribbonElement,
eventDisposer,
loadedDisposer,
resumedDisposer,
setEnabled(value: boolean) {
settingEnabled = value;
},
get loadedHandler() {
return loadedHandler;
},
get resumedHandler() {
return resumedHandler;
},
get unloadHandler() {
return unloadHandler;
},
};
}
describe("useCustomisationSyncUI", () => {
beforeEach(() => {
vi.clearAllMocks();
delete (globalThis as { activeDocument?: unknown }).activeDocument;
currentEventDisposer = vi.fn();
vi.mocked(eventHub.onEvent).mockImplementation((_event, callback) => {
fixtureEventCallback = callback as Handler;
return currentEventDisposer;
});
});
let fixtureEventCallback: Handler | undefined;
let currentEventDisposer = vi.fn();
it("registers the command and gates it on the focused view's enabled state", async () => {
const fixture = createFixture(false);
const control = useCustomisationSyncUI(
fixture.host,
{} as any,
fixture.customisationSync,
fixture.hiddenFileSync,
fixture.createDialog
);
expect(addIcon).not.toHaveBeenCalled();
await fixture.loadedHandler?.();
expect(addIcon).toHaveBeenCalledWith("custom-sync", expect.stringContaining("rotate(-90 75 218)"));
const command = fixture.commands.find(({ id }) => id === "livesync-plugin-dialog-ex");
expect(command?.checkCallback?.(true)).toBe(false);
expect(command?.checkCallback?.(false)).toBe(false);
expect(fixture.createDialog).not.toHaveBeenCalled();
fixture.setEnabled(true);
expect(command?.checkCallback?.(true)).toBe(true);
expect(command?.checkCallback?.(false)).toBe(true);
expect(fixture.createDialog).toHaveBeenCalledOnce();
expect(control.isOpen()).toBe(true);
});
it("opens from the request event and ribbon, reusing one modal instance", async () => {
const fixture = createFixture(true);
const control = useCustomisationSyncUI(
fixture.host,
{} as any,
fixture.customisationSync,
fixture.hiddenFileSync,
fixture.createDialog
);
await fixture.loadedHandler?.();
expect(fixture.ribbonElement.addClass).toHaveBeenCalledWith("livesync-ribbon-showcustom");
expect(fixture.host.services.API.addRibbonIcon).toHaveBeenCalledWith(
"custom-sync",
"cmdConfigSync.showCustomizationSync",
expect.any(Function)
);
expect(fixture.ribbonCallbacks).toHaveLength(1);
expect(fixtureEventCallback).toBeDefined();
fixtureEventCallback?.();
fixture.ribbonCallbacks[0]?.();
expect(fixture.createDialog).toHaveBeenCalledOnce();
expect(fixture.modal.open).toHaveBeenCalledTimes(2);
expect(control.isOpen()).toBe(true);
});
it("updates resumed ribbon visibility and releases the event, ribbon, and modal on unload", async () => {
const ribbonElement = {
toggleClass: vi.fn(),
};
Object.defineProperty(globalThis, "activeDocument", {
configurable: true,
value: {
querySelector: vi.fn(() => ribbonElement),
},
});
const fixture = createFixture(true);
currentEventDisposer = fixture.eventDisposer;
const control = useCustomisationSyncUI(
fixture.host,
{} as any,
fixture.customisationSync,
fixture.hiddenFileSync,
fixture.createDialog
);
await fixture.loadedHandler?.();
await fixture.resumedHandler?.();
expect(ribbonElement.toggleClass).toHaveBeenCalledWith("sls-hidden", false);
control.open();
await fixture.unloadHandler?.();
expect(fixture.modal.close).toHaveBeenCalledOnce();
expect(fixture.eventDisposer).toHaveBeenCalledOnce();
expect(fixture.ribbonElement.remove).toHaveBeenCalledOnce();
expect(fixture.loadedDisposer).toHaveBeenCalledOnce();
expect(fixture.resumedDisposer).toHaveBeenCalledOnce();
expect(control.isOpen()).toBe(false);
});
});
@@ -0,0 +1,65 @@
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import type { HiddenFileSyncCommandView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
export type HiddenFileSyncCommandHost = NecessaryServices<"API" | "appLifecycle", never>;
/** Register the Obsidian commands and event bridge for Hidden File Sync. */
export function useHiddenFileSyncCommands(
host: HiddenFileSyncCommandHost,
operations: HiddenFileSyncCommandView
): void {
const { API, appLifecycle } = host.services;
let registered = false;
let disposed = false;
let settingSavedDisposer: (() => void) | undefined;
let loadedDisposer: (() => void) | undefined;
let unloadDisposer: (() => void) | undefined;
const checkAndRun = (checking: boolean, operation: () => void) => {
if (!operations.isManualCommandAvailable()) return false;
if (!checking) operation();
return true;
};
const registerCommands = () => {
if (registered || disposed) return Promise.resolve(true);
registered = true;
API.addCommand({
id: "livesync-sync-internal",
name: "(re)initialise hidden files between storage and database",
checkCallback: (checking) =>
checkAndRun(checking, () => void operations.initialiseInternalFileSync("safe", true)),
});
API.addCommand({
id: "livesync-scaninternal-storage",
name: "Scan hidden file changes on the storage",
checkCallback: (checking) => checkAndRun(checking, () => void operations.scanAllStorageChanges(true)),
});
API.addCommand({
id: "livesync-scaninternal-database",
name: "Scan hidden file changes on the local database",
checkCallback: (checking) => checkAndRun(checking, () => void operations.scanAllDatabaseChanges(true)),
});
API.addCommand({
id: "livesync-internal-scan-offline-changes",
name: "Scan and apply all offline hidden-file changes",
checkCallback: (checking) => checkAndRun(checking, () => void operations.applyOfflineChanges(true)),
});
settingSavedDisposer = eventHub.onEvent(EVENT_SETTING_SAVED, () => operations.updateSettingCache());
return Promise.resolve(true);
};
loadedDisposer = appLifecycle.onLoaded.addHandler(registerCommands);
unloadDisposer = appLifecycle.onUnload.addHandler(() => {
disposed = true;
settingSavedDisposer?.();
settingSavedDisposer = undefined;
loadedDisposer?.();
loadedDisposer = undefined;
unloadDisposer?.();
unloadDisposer = undefined;
return Promise.resolve(true);
});
}
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/common/events.ts", () => ({
EVENT_SETTING_SAVED: "setting-saved",
eventHub: {
onEvent: vi.fn(() => vi.fn()),
},
}));
import { eventHub } from "@/common/events.ts";
import { useHiddenFileSyncCommands } from "./useHiddenFileSyncCommands.ts";
type Handler = () => Promise<boolean> | boolean;
function handlerRegistry() {
const handlers: Handler[] = [];
return {
addHandler: vi.fn((handler: Handler) => {
handlers.push(handler);
return () => {
const index = handlers.indexOf(handler);
if (index >= 0) handlers.splice(index, 1);
};
}),
handlers,
};
}
function createFixture() {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const onLoaded = handlerRegistry();
const onUnload = handlerRegistry();
const operations = {
isManualCommandAvailable: vi.fn(() => true),
initialiseInternalFileSync: vi.fn(async () => undefined),
scanAllStorageChanges: vi.fn(async () => undefined),
scanAllDatabaseChanges: vi.fn(async () => undefined),
applyOfflineChanges: vi.fn(async () => undefined),
updateSettingCache: vi.fn(),
};
const host = {
services: {
API: {
addCommand: vi.fn((command) => commands.push(command)),
},
appLifecycle: { onLoaded, onUnload },
},
};
useHiddenFileSyncCommands(host as never, operations);
return { commands, host, onLoaded, onUnload, operations };
}
describe("useHiddenFileSyncCommands", () => {
it("registers the established commands after loading and delegates their actions", async () => {
const { commands, onLoaded, operations } = createFixture();
await onLoaded.handlers[0]!();
expect(commands.map(({ id }) => id)).toEqual([
"livesync-sync-internal",
"livesync-scaninternal-storage",
"livesync-scaninternal-database",
"livesync-internal-scan-offline-changes",
]);
for (const command of commands) {
expect(command.checkCallback?.(true)).toBe(true);
command.checkCallback?.(false);
}
expect(operations.initialiseInternalFileSync).toHaveBeenCalledWith("safe", true);
expect(operations.scanAllStorageChanges).toHaveBeenCalledWith(true);
expect(operations.scanAllDatabaseChanges).toHaveBeenCalledWith(true);
expect(operations.applyOfflineChanges).toHaveBeenCalledWith(true);
});
it("short-circuits every command through the operation view", async () => {
const { commands, onLoaded, operations } = createFixture();
operations.isManualCommandAvailable.mockReturnValue(false);
await onLoaded.handlers[0]!();
for (const command of commands) {
expect(command.checkCallback?.(true)).toBe(false);
expect(command.checkCallback?.(false)).toBe(false);
}
expect(operations.initialiseInternalFileSync).not.toHaveBeenCalled();
expect(operations.scanAllStorageChanges).not.toHaveBeenCalled();
expect(operations.scanAllDatabaseChanges).not.toHaveBeenCalled();
expect(operations.applyOfflineChanges).not.toHaveBeenCalled();
});
it("owns and releases the settings event subscription", async () => {
vi.mocked(eventHub.onEvent).mockClear();
const { onLoaded, onUnload, operations } = createFixture();
await onLoaded.handlers[0]!();
const [, listener] = vi.mocked(eventHub.onEvent).mock.calls[0]!;
const eventDisposer = vi.mocked(eventHub.onEvent).mock.results[0]!.value;
listener(undefined as never);
expect(operations.updateSettingCache).toHaveBeenCalledOnce();
await onUnload.handlers[0]!();
expect(eventDisposer).toHaveBeenCalledOnce();
expect(onLoaded.handlers).toHaveLength(0);
});
});
+240
View File
@@ -0,0 +1,240 @@
import type { FilePath, FilePathWithPrefix, UXFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import {
isCustomisationSyncMetadata,
isInternalMetadata,
isPluginMetadata,
} from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
import {
CustomisationSyncContext,
type CustomisationSyncContextDependencies,
} from "@/features/ConfigSync/customisationSyncContext.ts";
import type { CustomisationSyncDialogView } from "@/features/ConfigSync/customisationSyncView.ts";
import { HiddenFileSyncContext } from "@/features/HiddenFileSync/hiddenFileSyncContext.ts";
import type {
HiddenFileSyncCommandView,
HiddenFileSyncInitialisationView,
HiddenFileSyncRepairView,
} from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
import type { LiveSyncCore } from "@/main.ts";
import {
createCustomisationSyncObsidianDependencies,
type CustomisationSyncObsidianPolicies,
} from "./customisationSyncObsidianAdapter.ts";
import {
createHiddenFileSyncObsidianDependencies,
type HiddenFileSyncObsidianPolicies,
} from "./hiddenFileSyncObsidianAdapter.ts";
import {
isCustomisationSyncDocumentLocallyOwned,
routeOptionalFileSyncPath,
selectOptionalFileSyncOwner,
type OptionalFileSyncOwnerSelectionInput,
} from "./optionalFileSyncRouting.ts";
export interface OptionalFileSyncFeature {
readonly customisationSync: CustomisationSyncDialogView;
readonly hiddenFileSyncCommands: HiddenFileSyncCommandView;
readonly hiddenFileSyncInitialisation: HiddenFileSyncInitialisationView;
readonly hiddenFileSyncRepair: HiddenFileSyncRepairView;
/** @internal Direct runtime access for the repository's real-Obsidian contract tests. */
readonly testing: {
readonly customisationSync: CustomisationSyncContext;
readonly hiddenFileSync: HiddenFileSyncContext;
};
}
export type OptionalFileSyncDependencies = Pick<Partial<CustomisationSyncContextDependencies>, "getUIControl"> & {
createCustomisationSync?: (policies: CustomisationSyncObsidianPolicies) => CustomisationSyncContext;
createHiddenFileSync?: (policies: HiddenFileSyncObsidianPolicies) => HiddenFileSyncContext;
};
/**
* Compose Customisation Sync and Hidden File Sync as one optional-file owner.
*
* The two runtimes intentionally remain separate because they have distinct
* state and persistence rules. Their shared service handlers are registered
* here rather than as a side effect of constructing two add-ons. A pure
* policy selects one local writer before either runtime callback is invoked.
*/
export function useOptionalFileSync(
host: LiveSyncCore,
dependencies: OptionalFileSyncDependencies = {}
): OptionalFileSyncFeature {
const createCustomisationSync =
dependencies.createCustomisationSync ??
((policies: CustomisationSyncObsidianPolicies) =>
new CustomisationSyncContext(createCustomisationSyncObsidianDependencies(host, policies)));
const createHiddenFileSync =
dependencies.createHiddenFileSync ??
((policies: HiddenFileSyncObsidianPolicies) =>
new HiddenFileSyncContext(createHiddenFileSyncObsidianDependencies(host, policies)));
const ownerSelectionInput = (path: FilePath): OptionalFileSyncOwnerSelectionInput => ({
path,
configDir: host.services.API.getSystemConfigDir(),
useV2: host.settings.usePluginSyncV2,
usePluginEtc: host.settings.usePluginEtc,
customisationEnabled: host.settings.usePluginSync,
hiddenFileEnabled: host.settings.syncInternalFiles,
pluginSyncExtendedSetting: host.settings.pluginSyncExtendedSetting,
});
const ownsLocalFile = (owner: "customisation" | "hidden-file") => (path: FilePath) =>
selectOptionalFileSyncOwner(ownerSelectionInput(path)).owner == owner;
const customisationSync = createCustomisationSync({
getUIControl: () => dependencies.getUIControl?.(),
ownsLocalFile: ownsLocalFile("customisation"),
ownsLocalDocument: (documentPath) =>
isCustomisationSyncDocumentLocallyOwned({
documentPath,
customisationEnabled: host.settings.usePluginSync,
pluginSyncExtendedSetting: host.settings.pluginSyncExtendedSetting,
}),
});
const hiddenFileSync = createHiddenFileSync({
ownsLocalFile: ownsLocalFile("hidden-file"),
});
const { services } = host;
const disposers: (() => void)[] = [];
const register = (dispose: () => void) => {
disposers.push(dispose);
};
const normaliseLocalPath = (file: string | UXFileInfoStub) =>
stripAllPrefixes((typeof file === "string" ? file : file.path) as FilePathWithPrefix);
const routeLocalPath = async (path: FilePath) => {
const selected = selectOptionalFileSyncOwner(ownerSelectionInput(path));
const hiddenFileEligible =
selected.owner == "hidden-file" ? await hiddenFileSync.isTargetFileEligible(path) : false;
const ready = services.appLifecycle.isReady() && !services.appLifecycle.isSuspended();
return routeOptionalFileSyncPath({
...ownerSelectionInput(path),
customisationReady: ready,
hiddenFileReady: ready,
hiddenFileEligible,
});
};
register(
services.fileProcessing.processOptionalFileEvent.addHandler(async (path: FilePath) => {
const localPath = normaliseLocalPath(path);
const decision = await routeLocalPath(localPath);
if (decision.owner == "customisation") {
return await customisationSync._anyProcessOptionalFileEvent(localPath);
}
if (decision.owner == "hidden-file") {
return await hiddenFileSync._anyProcessOptionalFileEvent(localPath);
}
return false;
})
);
register(
services.conflict.getOptionalConflictCheckMethod.addHandler(async (path: FilePathWithPrefix) => {
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
return await customisationSync._anyGetOptionalConflictCheckMethod(path);
}
if (isInternalMetadata(path)) {
return await hiddenFileSync._anyGetOptionalConflictCheckMethod(path);
}
return false;
})
);
register(
services.replication.processVirtualDocument.addHandler(
customisationSync._anyModuleParsedReplicationResultItem.bind(customisationSync)
)
);
register(
services.replication.processOptionalSynchroniseResult.addHandler(
hiddenFileSync._anyProcessOptionalSyncFiles.bind(hiddenFileSync)
)
);
register(
services.appLifecycle.onSettingLoaded.addHandler(
hiddenFileSync._everyOnloadAfterLoadSettings.bind(hiddenFileSync)
)
);
register(
services.setting.onRealiseSetting.addHandler(
customisationSync._everyRealizeSettingSyncMode.bind(customisationSync)
)
);
register(
services.setting.onRealiseSetting.addHandler(hiddenFileSync._everyRealizeSettingSyncMode.bind(hiddenFileSync))
);
register(
services.appLifecycle.onResuming.addHandler(customisationSync._everyOnResumeProcess.bind(customisationSync))
);
register(services.appLifecycle.onResuming.addHandler(hiddenFileSync._everyOnResumeProcess.bind(hiddenFileSync)));
register(
services.replication.onBeforeReplicate.addHandler(
customisationSync._everyBeforeReplicate.bind(customisationSync)
)
);
register(
services.replication.onBeforeReplicate.addHandler(hiddenFileSync._everyBeforeReplicate.bind(hiddenFileSync))
);
register(
services.databaseEvents.onDatabaseInitialised.addHandler(
customisationSync._everyOnDatabaseInitialized.bind(customisationSync)
)
);
register(
services.databaseEvents.onDatabaseInitialised.addHandler(
hiddenFileSync._everyOnDatabaseInitialized.bind(hiddenFileSync)
)
);
register(
services.setting.suspendExtraSync.addHandler(customisationSync._allSuspendExtraSync.bind(customisationSync))
);
register(services.setting.suspendExtraSync.addHandler(hiddenFileSync._allSuspendExtraSync.bind(hiddenFileSync)));
register(
services.setting.enableOptionalFeature.addHandler(
customisationSync._allConfigureOptionalSyncFeature.bind(customisationSync)
)
);
register(
services.setting.enableOptionalFeature.addHandler(
hiddenFileSync._allConfigureOptionalSyncFeature.bind(hiddenFileSync)
)
);
register(
services.vault.isTargetFileInExtra.addHandler(
async (file: string | UXFileInfoStub) => (await routeLocalPath(normaliseLocalPath(file))).owner != "none"
)
);
register(
services.appLifecycle.onUnload.addHandler(async () => {
let succeeded = true;
for (const dispose of disposers.splice(0)) {
try {
dispose();
} catch {
succeeded = false;
}
}
for (const context of [customisationSync, hiddenFileSync]) {
try {
await Promise.resolve(context.dispose());
} catch {
succeeded = false;
}
}
return succeeded;
})
);
return Object.freeze({
customisationSync,
hiddenFileSyncCommands: hiddenFileSync,
hiddenFileSyncInitialisation: hiddenFileSync,
hiddenFileSyncRepair: hiddenFileSync,
testing: Object.freeze({ customisationSync, hiddenFileSync }),
});
}
@@ -0,0 +1,400 @@
import { describe, expect, it, vi } from "vitest";
import {
allSettledFunction,
anySuccessFunction,
bailFirstFailureFunction,
firstResultFunction,
} from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import { MODE_AUTOMATIC, MODE_PAUSED } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/features/ConfigSync/customisationSyncContext.ts", () => ({
CustomisationSyncContext: class CustomisationSyncContext {},
}));
vi.mock("@/features/HiddenFileSync/hiddenFileSyncContext.ts", () => ({
HiddenFileSyncContext: class HiddenFileSyncContext {},
}));
vi.mock("./customisationSyncObsidianAdapter.ts", () => ({
createCustomisationSyncObsidianDependencies: vi.fn(),
}));
vi.mock("./hiddenFileSyncObsidianAdapter.ts", () => ({
createHiddenFileSyncObsidianDependencies: vi.fn(),
}));
import { useOptionalFileSync } from "./useOptionalFileSync.ts";
type Handler = (...args: any[]) => any;
function handlerRegistry() {
const handlers: Handler[] = [];
return {
addHandler: vi.fn((handler: Handler) => {
handlers.push(handler);
return () => {
const index = handlers.indexOf(handler);
if (index >= 0) handlers.splice(index, 1);
};
}),
handlers,
};
}
function createFixture() {
const processOptionalFileEvent = handlerRegistry();
const getOptionalConflictCheckMethod = handlerRegistry();
const processVirtualDocument = handlerRegistry();
const processOptionalSynchroniseResult = handlerRegistry();
const onRealiseSetting = handlerRegistry();
const onSettingLoaded = handlerRegistry();
const onResuming = handlerRegistry();
const onBeforeReplicate = handlerRegistry();
const onDatabaseInitialised = handlerRegistry();
const suspendExtraSync = handlerRegistry();
const enableOptionalFeature = handlerRegistry();
const isTargetFileInExtra = handlerRegistry();
const onUnload = handlerRegistry();
const calls: string[] = [];
const customisationSync = {
dispose: vi.fn(() => calls.push("customisation:unload")),
_anyProcessOptionalFileEvent: vi.fn(async () => false),
_anyGetOptionalConflictCheckMethod: vi.fn(async () => false),
_anyModuleParsedReplicationResultItem: vi.fn(async () => false),
_everyRealizeSettingSyncMode: vi.fn(async () => true),
_everyOnResumeProcess: vi.fn(async () => true),
_everyBeforeReplicate: vi.fn(async () => true),
_everyOnDatabaseInitialized: vi.fn(async () => true),
_allSuspendExtraSync: vi.fn(async () => true),
_allConfigureOptionalSyncFeature: vi.fn(async () => true),
};
const hiddenFileSync = {
dispose: vi.fn(() => calls.push("hidden:unload")),
_everyOnloadAfterLoadSettings: vi.fn(async () => true),
_anyProcessOptionalFileEvent: vi.fn(async () => false),
_anyGetOptionalConflictCheckMethod: vi.fn(async () => false),
_anyProcessOptionalSyncFiles: vi.fn(async () => false),
_everyRealizeSettingSyncMode: vi.fn(async () => true),
_everyOnResumeProcess: vi.fn(async () => true),
_everyBeforeReplicate: vi.fn(async () => true),
_everyOnDatabaseInitialized: vi.fn(async () => true),
_allSuspendExtraSync: vi.fn(async () => true),
_allConfigureOptionalSyncFeature: vi.fn(async () => true),
isTargetFileEligible: vi.fn(async () => true),
};
const settings = {
usePluginSync: true,
usePluginSyncV2: true,
usePluginEtc: true,
syncInternalFiles: true,
pluginSyncExtendedSetting: {},
};
const services = {
fileProcessing: { processOptionalFileEvent },
conflict: { getOptionalConflictCheckMethod },
replication: { processVirtualDocument, processOptionalSynchroniseResult, onBeforeReplicate },
setting: { onRealiseSetting, suspendExtraSync, enableOptionalFeature },
appLifecycle: {
onSettingLoaded,
onResuming,
onUnload,
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
},
databaseEvents: { onDatabaseInitialised },
vault: { isTargetFileInExtra },
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
};
const host = { services, settings };
let customisationDependencies: unknown;
let hiddenFileDependencies: unknown;
const feature = useOptionalFileSync(host as never, {
createCustomisationSync: (contextDependencies) => {
customisationDependencies = contextDependencies;
return customisationSync as never;
},
createHiddenFileSync: (contextDependencies) => {
hiddenFileDependencies = contextDependencies;
return hiddenFileSync as never;
},
});
return {
calls,
customisationSync,
feature,
hiddenFileSync,
settings,
contextDependencies: {
customisation: () => customisationDependencies,
hiddenFile: () => hiddenFileDependencies,
},
registries: {
enableOptionalFeature,
getOptionalConflictCheckMethod,
isTargetFileInExtra,
onBeforeReplicate,
onDatabaseInitialised,
onRealiseSetting,
onResuming,
onSettingLoaded,
onUnload,
processOptionalFileEvent,
processOptionalSynchroniseResult,
processVirtualDocument,
suspendExtraSync,
},
};
}
function createAggregateFixture() {
const customisationSync = {
dispose: vi.fn(),
_anyProcessOptionalFileEvent: vi.fn(async () => false),
_anyGetOptionalConflictCheckMethod: vi.fn(async (): Promise<boolean | "newer"> => false),
_anyModuleParsedReplicationResultItem: vi.fn(async () => false),
_everyRealizeSettingSyncMode: vi.fn(async () => true),
_everyOnResumeProcess: vi.fn(async () => true),
_everyBeforeReplicate: vi.fn(async () => true),
_everyOnDatabaseInitialized: vi.fn(async () => true),
_allSuspendExtraSync: vi.fn(async () => true),
_allConfigureOptionalSyncFeature: vi.fn(async () => true),
};
const hiddenFileSync = {
dispose: vi.fn(),
_everyOnloadAfterLoadSettings: vi.fn(async () => true),
_anyProcessOptionalFileEvent: vi.fn(async () => false),
_anyGetOptionalConflictCheckMethod: vi.fn(async (): Promise<boolean | "newer"> => false),
_anyProcessOptionalSyncFiles: vi.fn(async () => false),
_everyRealizeSettingSyncMode: vi.fn(async () => true),
_everyOnResumeProcess: vi.fn(async () => true),
_everyBeforeReplicate: vi.fn(async () => true),
_everyOnDatabaseInitialized: vi.fn(async () => true),
_allSuspendExtraSync: vi.fn(async () => true),
_allConfigureOptionalSyncFeature: vi.fn(async () => true),
isTargetFileEligible: vi.fn(async () => true),
};
const settings = {
usePluginSync: true,
usePluginSyncV2: true,
usePluginEtc: true,
syncInternalFiles: true,
pluginSyncExtendedSetting: {},
};
const booleanAnySuccess = (name: string) => anySuccessFunction<(...args: any[]) => Promise<boolean>>(name);
const booleanBail = (name: string) => bailFirstFailureFunction<(...args: any[]) => Promise<boolean>>(name);
const services = {
fileProcessing: { processOptionalFileEvent: booleanAnySuccess("processOptionalFileEvent") },
conflict: {
getOptionalConflictCheckMethod: firstResultFunction<(...args: any[]) => Promise<boolean | "newer">>(
"getOptionalConflictCheckMethod"
),
},
replication: {
processVirtualDocument: booleanAnySuccess("processVirtualDocument"),
processOptionalSynchroniseResult: booleanAnySuccess("processOptionalSynchroniseResult"),
onBeforeReplicate: booleanBail("onBeforeReplicate"),
},
setting: {
onRealiseSetting: booleanBail("onRealiseSetting"),
suspendExtraSync: booleanBail("suspendExtraSync"),
enableOptionalFeature: booleanBail("enableOptionalFeature"),
},
appLifecycle: {
onSettingLoaded: booleanBail("onSettingLoaded"),
onResuming: booleanBail("onResuming"),
onUnload: allSettledFunction<() => Promise<boolean>>("onUnload"),
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
},
databaseEvents: { onDatabaseInitialised: booleanBail("onDatabaseInitialised") },
vault: { isTargetFileInExtra: booleanAnySuccess("isTargetFileInExtra") },
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
};
useOptionalFileSync({ services, settings } as never, {
createCustomisationSync: () => customisationSync as never,
createHiddenFileSync: () => hiddenFileSync as never,
});
return { customisationSync, hiddenFileSync, services, settings };
}
describe("useOptionalFileSync", () => {
it("registers one owner-selecting handler for each overlapping result contract", () => {
const { registries } = createFixture();
expect(registries.processOptionalFileEvent.handlers).toHaveLength(1);
expect(registries.getOptionalConflictCheckMethod.handlers).toHaveLength(1);
expect(registries.onRealiseSetting.handlers).toHaveLength(2);
expect(registries.onResuming.handlers).toHaveLength(2);
expect(registries.onBeforeReplicate.handlers).toHaveLength(2);
expect(registries.onDatabaseInitialised.handlers).toHaveLength(2);
expect(registries.suspendExtraSync.handlers).toHaveLength(2);
expect(registries.enableOptionalFeature.handlers).toHaveLength(2);
expect(registries.processVirtualDocument.handlers).toHaveLength(1);
expect(registries.processOptionalSynchroniseResult.handlers).toHaveLength(1);
expect(registries.onSettingLoaded.handlers).toHaveLength(1);
expect(registries.isTargetFileInExtra.handlers).toHaveLength(1);
});
it("routes Selective and Automatic paths to exactly one local owner", async () => {
const { customisationSync, hiddenFileSync, registries, settings } = createFixture();
customisationSync._anyProcessOptionalFileEvent.mockResolvedValue(true);
hiddenFileSync._anyProcessOptionalFileEvent.mockResolvedValue(true);
hiddenFileSync.isTargetFileEligible.mockResolvedValue(false);
await expect(registries.isTargetFileInExtra.handlers[0]!(".obsidian/plugins/example/data.json")).resolves.toBe(
true
);
await expect(
registries.processOptionalFileEvent.handlers[0]!(".obsidian/plugins/example/data.json")
).resolves.toBe(true);
expect(customisationSync._anyProcessOptionalFileEvent).toHaveBeenCalledOnce();
expect(hiddenFileSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
expect(hiddenFileSync.isTargetFileEligible).not.toHaveBeenCalled();
settings.pluginSyncExtendedSetting = {
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode: MODE_AUTOMATIC,
files: [],
},
};
hiddenFileSync.isTargetFileEligible.mockResolvedValue(true);
customisationSync._anyProcessOptionalFileEvent.mockClear();
await expect(
registries.processOptionalFileEvent.handlers[0]!(".obsidian/plugins/example/data.json")
).resolves.toBe(true);
expect(hiddenFileSync._anyProcessOptionalFileEvent).toHaveBeenCalledOnce();
expect(customisationSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
});
it("does not fall back to the other context when the selected owner skips or fails", async () => {
const { customisationSync, hiddenFileSync, services } = createAggregateFixture();
customisationSync._anyProcessOptionalFileEvent.mockResolvedValueOnce(false);
await expect(services.fileProcessing.processOptionalFileEvent(".obsidian/app.json")).resolves.toBe(false);
expect(hiddenFileSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
customisationSync._anyProcessOptionalFileEvent.mockRejectedValueOnce(new Error("customisation failed"));
await expect(services.fileProcessing.processOptionalFileEvent(".obsidian/app.json")).resolves.toBe(false);
expect(hiddenFileSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
});
it("dispatches conflict documents by their persisted namespace", async () => {
const { customisationSync, hiddenFileSync, services } = createAggregateFixture();
customisationSync._anyGetOptionalConflictCheckMethod.mockResolvedValue("newer");
hiddenFileSync._anyGetOptionalConflictCheckMethod.mockResolvedValue(true);
await expect(services.conflict.getOptionalConflictCheckMethod("ix:device/app.json")).resolves.toBe("newer");
expect(hiddenFileSync._anyGetOptionalConflictCheckMethod).not.toHaveBeenCalled();
await expect(services.conflict.getOptionalConflictCheckMethod("i:.obsidian/example.json")).resolves.toBe(true);
expect(customisationSync._anyGetOptionalConflictCheckMethod).toHaveBeenCalledOnce();
});
it("keeps persisted document acceptance separate from current local ownership", async () => {
const { customisationSync, hiddenFileSync, registries, settings } = createFixture();
settings.usePluginSync = false;
settings.syncInternalFiles = false;
await registries.processVirtualDocument.handlers[0]!({ _id: "ix:device-a/CONFIG/app.json.md" });
await registries.processOptionalSynchroniseResult.handlers[0]!({ _id: "i:.obsidian/app.json" });
expect(customisationSync._anyModuleParsedReplicationResultItem).toHaveBeenCalledOnce();
expect(hiddenFileSync._anyProcessOptionalSyncFiles).toHaveBeenCalledOnce();
});
it("routes Ignore mode to neither local context", async () => {
const { customisationSync, hiddenFileSync, registries, settings } = createFixture();
settings.pluginSyncExtendedSetting = {
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode: MODE_PAUSED,
files: ["plugins/example/data.json"],
},
};
await expect(
registries.processOptionalFileEvent.handlers[0]!(".obsidian/plugins/example/data.json")
).resolves.toBe(false);
expect(customisationSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
expect(hiddenFileSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
});
it("injects the same static ownership policy into both scan contexts", () => {
const { contextDependencies, settings } = createFixture();
const customisation = contextDependencies.customisation() as {
ownsLocalFile(path: string): boolean;
ownsLocalDocument(path: string): boolean;
};
const hiddenFile = contextDependencies.hiddenFile() as {
ownsLocalFile(path: string): boolean;
};
const path = ".obsidian/plugins/example/data.json";
expect(customisation.ownsLocalFile(path)).toBe(true);
expect(hiddenFile.ownsLocalFile(path)).toBe(false);
expect(customisation.ownsLocalDocument("ix:device-a/PLUGIN_DATA/example.md")).toBe(true);
settings.pluginSyncExtendedSetting = {
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode: MODE_AUTOMATIC,
files: [],
},
};
expect(customisation.ownsLocalFile(path)).toBe(false);
expect(hiddenFile.ownsLocalFile(path)).toBe(true);
expect(customisation.ownsLocalDocument("ix:device-a/PLUGIN_DATA/example.md")).toBe(false);
});
it("preserves bail-first-failure and settled-unload behaviour", async () => {
const { customisationSync, hiddenFileSync, services } = createAggregateFixture();
customisationSync._everyRealizeSettingSyncMode.mockResolvedValueOnce(false);
await expect(services.setting.onRealiseSetting()).resolves.toBe(false);
expect(hiddenFileSync._everyRealizeSettingSyncMode).not.toHaveBeenCalled();
customisationSync.dispose.mockImplementationOnce(() => {
throw new Error("customisation disposal failed");
});
await expect(services.appLifecycle.onUnload()).resolves.toBe(false);
expect(hiddenFileSync.dispose).toHaveBeenCalledOnce();
});
it("disposes both contexts and their registrations through the application lifecycle", async () => {
const { calls, customisationSync, hiddenFileSync, registries } = createFixture();
customisationSync.dispose.mockImplementation(() =>
calls.push(`customisation:unload:${registries.processOptionalFileEvent.handlers.length}`)
);
hiddenFileSync.dispose.mockImplementation(() =>
calls.push(`hidden:unload:${registries.processOptionalFileEvent.handlers.length}`)
);
await registries.onUnload.handlers[0]!();
expect(calls).toEqual(["customisation:unload:0", "hidden:unload:0"]);
expect(registries.processOptionalFileEvent.handlers).toHaveLength(0);
expect(registries.getOptionalConflictCheckMethod.handlers).toHaveLength(0);
});
it("strips a database prefix before evaluating a Hidden File Sync target", async () => {
const { hiddenFileSync, registries } = createFixture();
await registries.isTargetFileInExtra.handlers[0]!({ path: "i:.obsidian/workspace" });
expect(hiddenFileSync.isTargetFileEligible).toHaveBeenCalledWith(".obsidian/workspace");
});
it("returns focused views without registering either context as an add-on", () => {
const { customisationSync, feature, hiddenFileSync } = createFixture();
expect(feature.customisationSync).toBe(customisationSync);
expect(feature.hiddenFileSyncCommands).toBe(hiddenFileSync);
expect(feature.hiddenFileSyncInitialisation).toBe(hiddenFileSync);
expect(feature.hiddenFileSyncRepair).toBe(hiddenFileSync);
expect(feature.testing).toEqual({ customisationSync, hiddenFileSync });
expect(Object.isFrozen(feature.testing)).toBe(true);
});
});
+2 -2
View File
@@ -182,9 +182,9 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
`test:e2e:obsidian:document-history-restore` creates a normal note, records a logical deletion while retaining readable chunks, and restores the deleted content through the visible Document History dialogue. It requires the action itself to create and reflect a new non-deleted successor revision, reopens the history at that successor, and captures the file picker, readable deleted revision, restored Vault file, and new successor revision. This scenario owns the ordinary-history restoration boundary; conflict resolution remains with **Inspect conflicts and file/database differences**.
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Its mixed-ownership check enables both optional-file features and proves that Selective, Automatic, and Ignore paths create a document in, respectively, only the `ix:` namespace, only the `i:` namespace, or neither namespace. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans real configuration, theme, snippet, and sample plug-in fixtures into every supported per-file category: `CONFIG`, `THEME`, `SNIPPET`, `PLUGIN_MAIN`, `PLUGIN_DATA`, and `PLUGIN_ETC`. It synchronises the entries through CouchDB, proves that they are not reflected before explicit application, applies each category on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
`test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`.
+153 -30
View File
@@ -54,6 +54,8 @@ const pluginDir = ".obsidian/plugins/livesync-e2e-sample";
const pluginManifestPath = `${pluginDir}/manifest.json`;
const pluginMainPath = `${pluginDir}/main.js`;
const pluginStylesPath = `${pluginDir}/styles.css`;
const pluginDataPath = `${pluginDir}/data.json`;
const pluginSupplementaryPath = `${pluginDir}/presets.json`;
const pluginManifestContent =
JSON.stringify(
{
@@ -77,9 +79,29 @@ const pluginMainContent = [
"",
].join("\n");
const pluginStylesContent = ".livesync-e2e-sample { color: #73548f; }\n";
const pluginDataContent = JSON.stringify({ enabled: true, source: "customisation-sync-e2e" }, null, 4) + "\n";
const pluginSupplementaryContent = JSON.stringify({ preset: "e2e", order: 1 }, null, 4) + "\n";
const themeDir = ".obsidian/themes/livesync-e2e-theme";
const themeManifestPath = `${themeDir}/manifest.json`;
const themeStylesPath = `${themeDir}/theme.css`;
const themeManifestContent =
JSON.stringify(
{
name: "LiveSync E2E Theme",
version: "0.0.1",
minAppVersion: "1.0.0",
author: "Self-hosted LiveSync",
},
null,
4
) + "\n";
const themeStylesContent = "body { --livesync-e2e-theme-colour: #3d6f54; }\n";
const sourceDeviceName = "customisation-sync-a";
const targetDeviceName = "customisation-sync-b";
type CustomisationCategory = "CONFIG" | "THEME" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_ETC" | "PLUGIN_DATA";
type GroupedCustomisationCategory = Extract<CustomisationCategory, "PLUGIN_MAIN" | "THEME">;
type RunnerContext = {
binary: string;
cliBinary: string;
@@ -162,6 +184,7 @@ async function startConfiguredSession(
deviceAndVaultName: deviceName,
usePluginSync: true,
usePluginSyncV2: true,
usePluginEtc: true,
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
syncInternalFiles: false,
@@ -202,15 +225,15 @@ async function scanCustomisations(cliBinary: string, env: NodeJS.ProcessEnv): Pr
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const before=await addOn.scanInternalFiles();",
"await addOn.scanAllConfigFiles(false);",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.customisationSync;",
"const before=await syncContext.scanInternalFiles();",
"await syncContext.scanAllConfigFiles(false);",
"return JSON.stringify({",
"ok:true,",
"enabled:core.settings.usePluginSync,",
"useV2:core.settings.usePluginSyncV2,",
"device:core.services.setting.getDeviceAndVaultName(),",
"configDir:addOn.configDir,",
"configDir:syncContext.configDir,",
"files:before,",
"});",
"})()",
@@ -226,11 +249,11 @@ async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv,
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.customisationSync;",
"const term=core.services.setting.getDeviceAndVaultName();",
"const stat=await core.storageAccess.statHidden(path);",
"const category=addOn.getFileCategory(path);",
"const result=await addOn.storeCustomizationFiles(path,term);",
"const category=syncContext.getFileCategory(path);",
"const result=await syncContext.storeCustomizationFiles(path,term);",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>doc.path);",
"const filename=path.split('/').pop();",
@@ -248,7 +271,7 @@ async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv,
async function deleteCustomisationSyncEntry(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
category: CustomisationCategory,
name: string,
term?: string
): Promise<void> {
@@ -260,11 +283,11 @@ async function deleteCustomisationSyncEntry(
`const name=${JSON.stringify(name)};`,
`const term=${JSON.stringify(term ?? "")};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.customisationSync;",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`))&&!doc.deleted&&!doc._deleted)||false;",
"if(!entry) throw new Error(`Could not find customisation sync entry to delete: ${category}/${name}`);",
"if(!(await addOn.deleteConfigOnDatabase(entry.path))){",
"if(!(await syncContext.deleteConfigOnDatabase(entry.path))){",
" throw new Error(`Could not delete Customisation Sync entry: ${entry.path}`);",
"}",
"return JSON.stringify({ok:true,path:entry.path});",
@@ -277,7 +300,7 @@ async function deleteCustomisationSyncEntry(
async function waitForCustomisationEntry(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
category: CustomisationCategory,
name: string,
term?: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
@@ -289,7 +312,7 @@ async function waitForCustomisationEntry(
async function waitForCustomisationEntries(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
category: CustomisationCategory,
name: string,
count: number,
term?: string,
@@ -329,7 +352,7 @@ async function waitForCustomisationEntries(
async function waitForCustomisationEntryAbsent(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
category: CustomisationCategory,
name: string,
term?: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
@@ -362,7 +385,7 @@ async function waitForCustomisationEntryAbsent(
async function applyRemoteCustomisationEntry(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
category: CustomisationCategory,
name: string,
term?: string
): Promise<void> {
@@ -374,16 +397,16 @@ async function applyRemoteCustomisationEntry(
`const name=${JSON.stringify(name)};`,
`const term=${JSON.stringify(term ?? "")};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.customisationSync;",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`)))||false;",
"if(!entry) throw new Error(`Could not find remote customisation entry: ${category}/${name}`);",
"const display=addOn.createPluginDataFromV2(entry.path);",
"const display=syncContext.createPluginDataFromV2(entry.path);",
"if(!display) throw new Error(`Could not create Customisation Sync display entry: ${entry.path}`);",
"const file=await addOn.createPluginDataExFileV2(entry.path);",
"const file=await syncContext.createPluginDataExFileV2(entry.path);",
"if(!file) throw new Error(`Could not load Customisation Sync file entry: ${entry.path}`);",
"await display.setFile(file);",
"if(!(await addOn.applyDataV2(display))){",
"if(!(await syncContext.applyDataV2(display))){",
" throw new Error(`Could not apply Customisation Sync entry: ${entry.path}`);",
"}",
"return JSON.stringify({ok:true,path:entry.path});",
@@ -396,7 +419,7 @@ async function applyRemoteCustomisationEntry(
async function applyRemoteCustomisationGroup(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "PLUGIN_MAIN",
category: GroupedCustomisationCategory,
name: string,
term?: string
): Promise<void> {
@@ -408,18 +431,18 @@ async function applyRemoteCustomisationGroup(
`const name=${JSON.stringify(name)};`,
`const term=${JSON.stringify(term ?? "")};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.customisationSync;",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`)));",
"if(entries.length===0) throw new Error(`Could not find remote customisation entries: ${category}/${name}`);",
"const display=addOn.createPluginDataFromV2(entries[0].path);",
"const display=syncContext.createPluginDataFromV2(entries[0].path);",
"if(!display) throw new Error(`Could not create Customisation Sync display entry: ${entries[0].path}`);",
"for(const entry of entries){",
" const file=await addOn.createPluginDataExFileV2(entry.path);",
" const file=await syncContext.createPluginDataExFileV2(entry.path);",
" if(!file) throw new Error(`Could not load Customisation Sync file entry: ${entry.path}`);",
" await display.setFile(file);",
"}",
"if(!(await addOn.applyDataV2(display))){",
"if(!(await syncContext.applyDataV2(display))){",
" throw new Error(`Could not apply Customisation Sync group: ${category}/${name}`);",
"}",
"return JSON.stringify({ok:true,count:entries.length});",
@@ -445,6 +468,7 @@ async function main(): Promise<void> {
const snippetName = snippetPathParts[snippetPathParts.length - 1] ?? snippetPath;
const configName = configPath.split("/").pop() ?? configPath;
const pluginName = pluginDir.split("/").pop() ?? pluginDir;
const themeName = themeDir.split("/").pop() ?? themeDir;
try {
await assertCouchDbReachable(couchDb);
@@ -460,6 +484,10 @@ async function main(): Promise<void> {
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
await writeVaultFile(vaultA.path, pluginDataPath, pluginDataContent);
await writeVaultFile(vaultA.path, pluginSupplementaryPath, pluginSupplementaryContent);
await writeVaultFile(vaultA.path, themeManifestPath, themeManifestContent);
await writeVaultFile(vaultA.path, themeStylesPath, themeStylesContent);
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
@@ -469,7 +497,11 @@ async function main(): Promise<void> {
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginManifestPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginMainPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginStylesPath);
const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName);
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginDataPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginSupplementaryPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, themeManifestPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, themeStylesPath);
const snippetEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName);
const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName);
const pluginEntries = await waitForCustomisationEntries(
context.cliBinary,
@@ -478,10 +510,36 @@ async function main(): Promise<void> {
pluginName,
3
);
const pluginDataEntry = await waitForCustomisationEntry(
context.cliBinary,
session.cliEnv,
"PLUGIN_DATA",
pluginName
);
const pluginSupplementaryEntry = await waitForCustomisationEntry(
context.cliBinary,
session.cliEnv,
"PLUGIN_ETC",
pluginName
);
const themeEntries = await waitForCustomisationEntries(
context.cliBinary,
session.cliEnv,
"THEME",
themeName,
2
);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
const entries = [entry, configEntry, ...pluginEntries];
const entries = [
snippetEntry,
configEntry,
...pluginEntries,
pluginDataEntry,
pluginSupplementaryEntry,
...themeEntries,
];
return entries.every(
(target) => ids.has(target.id) && target.children.every((childId) => ids.has(childId))
);
@@ -491,11 +549,33 @@ async function main(): Promise<void> {
session = await startConfiguredSession(context, vaultB, targetDeviceName);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
assertEqual(
await pathExists(vaultB.path, snippetPath),
false,
"Customisation Sync snippet was reflected before explicit application."
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName, sourceDeviceName);
await waitForCustomisationEntries(
context.cliBinary,
session.cliEnv,
"PLUGIN_MAIN",
pluginName,
3,
sourceDeviceName
);
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "PLUGIN_DATA", pluginName, sourceDeviceName);
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "PLUGIN_ETC", pluginName, sourceDeviceName);
await waitForCustomisationEntries(context.cliBinary, session.cliEnv, "THEME", themeName, 2, sourceDeviceName);
const unappliedPaths: Array<[path: string, description: string]> = [
[snippetPath, "snippet"],
[configPath, "configuration file"],
[pluginManifestPath, "plug-in main file"],
[pluginDataPath, "plug-in data file"],
[pluginSupplementaryPath, "plug-in supplementary file"],
[themeManifestPath, "theme file"],
];
for (const [path, description] of unappliedPaths) {
assertEqual(
await pathExists(vaultB.path, path),
false,
`Customisation Sync ${description} was reflected before explicit application.`
);
}
await applyRemoteCustomisationEntry(
context.cliBinary,
session.cliEnv,
@@ -528,6 +608,41 @@ async function main(): Promise<void> {
pluginStylesPath,
(content) => content === pluginStylesContent
);
await applyRemoteCustomisationEntry(
context.cliBinary,
session.cliEnv,
"PLUGIN_DATA",
pluginName,
sourceDeviceName
);
const appliedPluginData = await waitForPathContent(
vaultB.path,
pluginDataPath,
(content) => content === pluginDataContent
);
await applyRemoteCustomisationEntry(
context.cliBinary,
session.cliEnv,
"PLUGIN_ETC",
pluginName,
sourceDeviceName
);
const appliedPluginSupplementary = await waitForPathContent(
vaultB.path,
pluginSupplementaryPath,
(content) => content === pluginSupplementaryContent
);
await applyRemoteCustomisationGroup(context.cliBinary, session.cliEnv, "THEME", themeName, sourceDeviceName);
const appliedThemeManifest = await waitForPathContent(
vaultB.path,
themeManifestPath,
(content) => content === themeManifestContent
);
const appliedThemeStyles = await waitForPathContent(
vaultB.path,
themeStylesPath,
(content) => content === themeStylesContent
);
await session.app.stop();
assertEqual(applied, snippetContent, "Customisation Sync snippet content did not match after application.");
@@ -539,6 +654,14 @@ async function main(): Promise<void> {
);
assertEqual(appliedPluginMain, pluginMainContent, "Customisation Sync plug-in main file did not match.");
assertEqual(appliedPluginStyles, pluginStylesContent, "Customisation Sync plug-in stylesheet did not match.");
assertEqual(appliedPluginData, pluginDataContent, "Customisation Sync plug-in data did not match.");
assertEqual(
appliedPluginSupplementary,
pluginSupplementaryContent,
"Customisation Sync plug-in supplementary file did not match."
);
assertEqual(appliedThemeManifest, themeManifestContent, "Customisation Sync theme manifest did not match.");
assertEqual(appliedThemeStyles, themeStylesContent, "Customisation Sync theme stylesheet did not match.");
await writeVaultFile(vaultA.path, snippetPath, snippetUpdatedContent);
session = await startConfiguredSession(context, vaultA, sourceDeviceName);
@@ -589,7 +712,7 @@ async function main(): Promise<void> {
await session.app.stop();
console.log(
`Customisation Sync applied snippet, config, and plug-in fixtures, then propagated snippet update and sync-data deletion.`
`Customisation Sync applied configuration, theme, snippet, and plug-in main, data, and supplementary fixtures, then propagated snippet update and sync-data deletion.`
);
} finally {
await vaultA.dispose();
+58 -8
View File
@@ -745,14 +745,20 @@ async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
}
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
const liveSyncSettings = await settingsNavigator.openPage("Advanced");
const settingItem = liveSyncSettings.locator(".setting-item").filter({
has: settingsNavigator.page.getByText("Auto-accept compatible tweak mismatches", { exact: true }),
});
await settingItem.waitFor({ state: "visible", timeout: uiTimeoutMs });
const toggle = settingItem.locator(".checkbox-container");
if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) {
throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined.");
try {
const liveSyncSettings = await settingsNavigator.openPage("Advanced");
const settingItem = liveSyncSettings.locator(".setting-item").filter({
has: settingsNavigator.page.getByText("Auto-accept compatible tweak mismatches", { exact: true }),
});
await settingItem.waitFor({ state: "visible", timeout: uiTimeoutMs });
const toggle = settingItem.locator(".checkbox-container");
if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) {
throw new Error(
"The automatic compatible-setting policy was displayed as disabled while still undefined."
);
}
} finally {
await settingsNavigator.close();
}
});
}
@@ -873,6 +879,43 @@ async function executeRegisteredCommand(commandId: string): Promise<void> {
}
}
async function verifyCustomisationSyncDialogue(): Promise<string> {
const commandId = "obsidian-livesync:livesync-plugin-dialog-ex";
await executeRegisteredCommand(commandId);
const screenshotPath = await captureObsidianDialogue(
obsidianRemoteDebuggingPort(),
"customisation-sync-dialogue.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Customization Sync (Beta3)" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
for (const action of ["Scan changes", "Sync once", "Refresh", "Apply All Selected"]) {
await modal.getByRole("button", { name: action, exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
}
);
for (let openCount = 0; openCount < 2; openCount++) {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Customization Sync (Beta3)" }),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await page.keyboard.press("Escape");
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
if (openCount === 0) {
await executeRegisteredCommand(commandId);
}
}
return screenshotPath;
}
async function verifyLogAndReportSurfaces(): Promise<{ log: string; report: string }> {
await executeRegisteredCommand("obsidian-livesync:view-log");
const logScreenshot = await captureObsidianElement(
@@ -1198,6 +1241,8 @@ async function main(): Promise<void> {
syncAfterMerge: false,
periodicReplication: false,
useAdvancedMode: true,
usePluginSync: true,
deviceAndVaultName: "dialogue-mounts",
}
),
});
@@ -1233,6 +1278,11 @@ async function main(): Promise<void> {
`Compatibility review actions were stacked vertically, and the remote-size startup notice opened an untimed review dialogue successfully. Screenshots: ${remoteSizeScreenshots.compatibilityReview}, ${remoteSizeScreenshots.notice}, ${remoteSizeScreenshots.dialogue}`
);
const customisationSyncScreenshot = await verifyCustomisationSyncDialogue();
console.log(
`The Customisation Sync command mounted, closed, and remounted its focused-view dialogue successfully. Screenshot: ${customisationSyncScreenshot}`
);
const remoteScreenshot = await verifyRemoteSelectionDialogue("desktop");
console.log(`Remote selection dialogue mounted and closed successfully. Screenshot: ${remoteScreenshot}`);
const couchDBScreenshot = await verifyCouchDBSettingsDialogue("desktop");
@@ -1,5 +1,6 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { MODE_AUTOMATIC, MODE_PAUSED } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
@@ -60,6 +61,9 @@ const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
const targetPath = ".obsidian/livesync-targeted/only-a.json";
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
const mixedSelectivePath = ".obsidian/snippets/livesync-mixed-selective.css";
const mixedAutomaticPath = ".obsidian/snippets/livesync-mixed-automatic.css";
const mixedPausedPath = ".obsidian/snippets/livesync-mixed-paused.css";
type RunnerContext = {
binary: string;
@@ -144,8 +148,8 @@ async function scanHiddenStorage(cliBinary: string, env: NodeJS.ProcessEnv): Pro
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.scanAllStorageChanges(true);",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.hiddenFileSync;",
"await syncContext.scanAllStorageChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
@@ -159,8 +163,8 @@ async function scanHiddenDatabase(cliBinary: string, env: NodeJS.ProcessEnv): Pr
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.scanAllDatabaseChanges(true);",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.hiddenFileSync;",
"await syncContext.scanAllDatabaseChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
@@ -175,9 +179,9 @@ async function resolveHiddenConflicts(cliBinary: string, env: NodeJS.ProcessEnv)
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.resolveConflictOnInternalFiles();",
"await addOn.scanAllDatabaseChanges(true);",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.hiddenFileSync;",
"await syncContext.resolveConflictOnInternalFiles();",
"await syncContext.scanAllDatabaseChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
@@ -194,7 +198,7 @@ async function autoMergeHiddenJsonConflict(cliBinary: string, env: NodeJS.Proces
`const path=${JSON.stringify(path)};`,
"const prefixedPath=`i:${path}`;",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.hiddenFileSync;",
"let doc=false;",
"for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){",
" if(entry.path===prefixedPath){ doc=entry; break; }",
@@ -214,13 +218,13 @@ async function autoMergeHiddenJsonConflict(cliBinary: string, env: NodeJS.Proces
"if(!result){",
" throw new Error(`Hidden JSON conflict was not auto-mergeable: ${path}; base=${commonBase}; current=${doc._rev}; conflict=${conflictedRev}`);",
"}",
"await addOn.ensureDir(path);",
"const stat=await addOn.writeFile(path,result);",
"await syncContext.ensureDir(path);",
"const stat=await syncContext.writeFile(path,result);",
"if(!stat) throw new Error(`Could not write merged hidden file: ${path}`);",
"await addOn.storeInternalFileToDatabase({path,mtime:stat.mtime,ctime:stat.ctime,size:stat.size},true);",
"await syncContext.storeInternalFileToDatabase({path,mtime:stat.mtime,ctime:stat.ctime,size:stat.size},true);",
"await core.localDatabase.removeRevision(doc._id,conflictedRev);",
"await addOn.extractInternalFileFromDatabase(path);",
"await addOn.scanAllDatabaseChanges(true);",
"await syncContext.extractInternalFileFromDatabase(path);",
"await syncContext.scanAllDatabaseChanges(true);",
"return JSON.stringify({ok:true,merged:JSON.parse(result)});",
"})()",
].join(""),
@@ -236,7 +240,7 @@ async function openHiddenJsonResolveModal(cliBinary: string, env: NodeJS.Process
`const path=${JSON.stringify(path)};`,
"const prefixedPath=`i:${path}`;",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.hiddenFileSync;",
"let doc=false;",
"for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){",
" if(entry.path===prefixedPath){ doc=entry; break; }",
@@ -246,7 +250,7 @@ async function openHiddenJsonResolveModal(cliBinary: string, env: NodeJS.Process
"const docA=await core.localDatabase.getDBEntry(prefixedPath,{rev:doc._rev});",
"const docB=await core.localDatabase.getDBEntry(prefixedPath,{rev:conflicts[0]});",
"if(docA===false||docB===false) throw new Error(`Could not load conflicted hidden JSON entries: ${path}`);",
"void addOn.showJSONMergeDialogAndMerge(docA,docB);",
"void syncContext.showJSONMergeDialogAndMerge(docA,docB);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
@@ -267,10 +271,10 @@ async function storeHiddenFileAsConflict(
`const path=${JSON.stringify(path)};`,
`const baseRev=${JSON.stringify(baseRev)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"const fileInfo=await addOn.loadFileWithInfo(path);",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.hiddenFileSync;",
"const fileInfo=await syncContext.loadFileWithInfo(path);",
"if(fileInfo.deleted) throw new Error(`Hidden file was unexpectedly deleted: ${path}`);",
"const baseData=await addOn.__loadBaseSaveData(path,true);",
"const baseData=await syncContext.__loadBaseSaveData(path,true);",
"if(baseData===false) throw new Error(`Could not load base save data: ${path}`);",
"const saveData={",
" ...baseData,",
@@ -509,6 +513,91 @@ async function runTargetMismatch(
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target.");
}
async function runMixedOwnership(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const content = ".livesync-mixed-owner { color: #245a70; }\n";
await writeVaultFile(vault.path, mixedSelectivePath, content);
await writeVaultFile(vault.path, mixedAutomaticPath, content);
await writeVaultFile(vault.path, mixedPausedPath, content);
const session = await startConfiguredSession(context, vault, {
deviceAndVaultName: "mixed-ownership",
usePluginSync: true,
usePluginSyncV2: true,
usePluginEtc: true,
pluginSyncExtendedSetting: {
"SNIPPET/livesync-mixed-automatic.css": {
key: "SNIPPET/livesync-mixed-automatic.css",
mode: MODE_AUTOMATIC,
files: ["snippets/livesync-mixed-automatic.css"],
},
"SNIPPET/livesync-mixed-paused.css": {
key: "SNIPPET/livesync-mixed-paused.css",
mode: MODE_PAUSED,
files: ["snippets/livesync-mixed-paused.css"],
},
},
});
try {
const result = await evalObsidianJson<{ hiddenPaths: string[]; customisationPaths: string[] }>(
context.cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const core=plugin.core;",
"const customisation=plugin.optionalFileSync.testing.customisationSync;",
"const hidden=plugin.optionalFileSync.testing.hiddenFileSync;",
"core.services.setting.setDeviceAndVaultName('mixed-ownership');",
"await customisation.scanAllConfigFiles(false);",
"await hidden.scanAllStorageChanges(false,false,true,true);",
"const customisationPaths=[];",
"for await(const entry of core.localDatabase.findEntries('ix:','ix;')){customisationPaths.push(entry.path);}",
"const hiddenPaths=[];",
"for await(const entry of core.localDatabase.findEntries('i:','i;')){hiddenPaths.push(entry.path);}",
"return JSON.stringify({customisationPaths,hiddenPaths});",
"})()",
].join(""),
session.cliEnv,
hiddenFileCliTimeoutMs
);
const selectiveDocument =
"ix:mixed-ownership/SNIPPET/livesync-mixed-selective.css%livesync-mixed-selective.css";
const automaticDocument = `i:${mixedAutomaticPath}`;
assertEqual(
result.customisationPaths.includes(selectiveDocument),
true,
"Selective mode did not create its Customisation Sync document."
);
assertEqual(
result.hiddenPaths.includes(`i:${mixedSelectivePath}`),
false,
"Selective mode also created a Hidden File Sync document."
);
assertEqual(
result.hiddenPaths.includes(automaticDocument),
true,
"Automatic mode did not create its Hidden File Sync document."
);
assertEqual(
result.customisationPaths.some((path) => path.includes("livesync-mixed-automatic.css")),
false,
"Automatic mode also created a Customisation Sync document."
);
assertEqual(
result.hiddenPaths.includes(`i:${mixedPausedPath}`) ||
result.customisationPaths.some((path) => path.includes("livesync-mixed-paused.css")),
false,
"Ignore mode created an optional-file document."
);
} finally {
await session.app.stop();
}
console.log(
"Mixed optional-file ownership stored Selective, Automatic, and Ignore paths in at most one namespace."
);
}
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
@@ -516,7 +605,7 @@ async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], incl
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
const syncContext = plugin.optionalFileSync.testing.hiddenFileSync;
for (const id of ["alpha", "beta", "gamma"]) {
const pluginId = `livesync-e2e-${id}`;
obsidianApp.plugins.manifests[pluginId] = {
@@ -531,14 +620,14 @@ async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], incl
};
obsidianApp.plugins.enabledPlugins.add(pluginId);
}
addOn.queuedNotificationFiles.clear();
syncContext.queuedNotificationFiles.clear();
for (const id of nextItemIds) {
addOn.queuedNotificationFiles.add(`.obsidian/plugins/livesync-e2e-${id}`);
syncContext.queuedNotificationFiles.add(`.obsidian/plugins/livesync-e2e-${id}`);
}
if (nextIncludeRestart) {
addOn.queuedNotificationFiles.add(core.services.API.getSystemConfigDir());
syncContext.queuedNotificationFiles.add(core.services.API.getSystemConfigDir());
}
addOn.notifyConfigChange();
syncContext.notifyConfigChange();
},
{ nextItemIds: itemIds, nextIncludeRestart: includeRestart }
);
@@ -571,11 +660,14 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
await withObsidianPage(port, async (page) => {
const deadline = Date.now() + timeoutMs;
while ((await page.locator(".notice:visible").count()) > 0 && Date.now() < deadline) {
await page.locator(".notice:visible").first().click({
force: true,
position: { x: 2, y: 2 },
timeout: timeoutMs,
});
await page
.locator(".notice:visible")
.first()
.click({
force: true,
position: { x: 2, y: 2 },
timeout: timeoutMs,
});
}
assertEqual(
await page.locator(".notice:visible").count(),
@@ -588,10 +680,10 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
const syncContext = plugin.optionalFileSync.testing.hiddenFileSync;
const setting = core.services.setting;
const originalApplyPartial = setting.applyPartial;
const originalRebuildMerging = addOn.rebuildMerging;
const originalRebuildMerging = syncContext.rebuildMerging;
const state = {
done: false,
reachedPreparation: false,
@@ -639,12 +731,12 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
return await originalApplyPartial.apply(setting, args);
};
addOn.rebuildMerging = async (...args: unknown[]) => {
syncContext.rebuildMerging = async (...args: unknown[]) => {
state.reachedInitialisation = true;
await new Promise<void>((resolve) => {
state.releaseInitialisation = resolve;
});
return await originalRebuildMerging.apply(addOn, args);
return await originalRebuildMerging.apply(syncContext, args);
};
void core.services.setting
@@ -660,7 +752,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
)
.finally(() => {
setting.applyPartial = originalApplyPartial;
addOn.rebuildMerging = originalRebuildMerging;
syncContext.rebuildMerging = originalRebuildMerging;
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
@@ -707,17 +799,15 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
const result = await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const state = (globalThis as unknown as Record<
string,
{ releasePreparation?: () => void } | undefined
>)[stateKey];
const state = (
globalThis as unknown as Record<string, { releasePreparation?: () => void } | undefined>
)[stateKey];
state?.releasePreparation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedInitialisation?: boolean } | undefined>)[
stateKey
]?.reachedInitialisation === true,
(globalThis as unknown as Record<string, { reachedInitialisation?: boolean } | undefined>)[stateKey]
?.reachedInitialisation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
@@ -872,6 +962,7 @@ async function main(): Promise<void> {
await runJsonConflictRoundTrip(context, vaultA, vaultB);
await runJsonManualConflictResolution(context, vaultB);
await runTargetMismatch(context, vaultA, vaultB);
await runMixedOwnership(context, vaultB);
await runInitialisationNoticeGrouping(context, vaultB);
await runConfigurationNoticeGrouping(context, vaultB);
} finally {
@@ -589,8 +589,8 @@ async function scanHiddenStorage(cliBinary: string, environment: NodeJS.ProcessE
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.scanAllStorageChanges(true);",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.hiddenFileSync;",
"await syncContext.scanAllStorageChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
@@ -605,8 +605,8 @@ async function scanHiddenDatabase(cliBinary: string, environment: NodeJS.Process
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.scanAllDatabaseChanges(true);",
"const syncContext=app.plugins.plugins['obsidian-livesync'].optionalFileSync.testing.hiddenFileSync;",
"await syncContext.scanAllDatabaseChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),