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