Extract optional-file sync context capabilities

This commit is contained in:
vorotamoroz committed 2026-09-04 06:23:54 +00:00
1 parent 8449f9d3f5
commit 355d819f43
20 files changed
+775 -445

No files matched your search

@@ -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);
});
});