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