Extract optional-file sync context capabilities

This commit is contained in:
vorotamoroz
2026-09-04 06:23:54 +00:00
parent 8449f9d3f5
commit 355d819f43
20 changed files with 775 additions and 445 deletions
@@ -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.
@@ -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();
});
});
@@ -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);
});
});
+17 -12
View File
@@ -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();