mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 18:43:20 +00:00
Extract optional-file sync context capabilities
This commit is contained in:
1 parent
8449f9d3f5
commit
355d819f43
20 files changed
+775
-445
No files matched your search
+1
-6
@@ -29,7 +29,7 @@ vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
|
||||
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
|
||||
|
||||
describe("CustomisationSyncContext state ownership", () => {
|
||||
describe("CustomisationSyncContext composition", () => {
|
||||
it("does not share catalogue or presentation state between context instances", () => {
|
||||
const first = new CustomisationSyncContext(createCustomisationSyncTestDependencies());
|
||||
const second = new CustomisationSyncContext(createCustomisationSyncTestDependencies());
|
||||
@@ -66,14 +66,9 @@ describe("CustomisationSyncContext state ownership", () => {
|
||||
"createPluginDataExFileV2",
|
||||
"createPluginDataFromV2",
|
||||
"deleteConfigOnDatabase",
|
||||
"filenameToUnifiedKey",
|
||||
"filenameWithUnifiedKey",
|
||||
"getFileCategory",
|
||||
"isTargetPath",
|
||||
"scanAllConfigFiles",
|
||||
"scanInternalFiles",
|
||||
"storeCustomizationFiles",
|
||||
"unifiedKeyPrefixOfTerminal",
|
||||
].sort()
|
||||
);
|
||||
expect("catalogue" in context.testing).toBe(false);
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
|
||||
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
|
||||
|
||||
function createConfigSync(options: { useV2?: boolean; usePluginEtc?: boolean } = {}) {
|
||||
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
|
||||
Object.assign(configSync, {
|
||||
dependencies: createCustomisationSyncTestDependencies({
|
||||
getConfigDir: () => ".obsidian",
|
||||
getSettings: () => ({
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: options.useV2 ?? true,
|
||||
usePluginEtc: options.usePluginEtc ?? true,
|
||||
pluginSyncExtendedSetting: {},
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
watchInternalFileChanges: false,
|
||||
notifyPluginOrSettingUpdated: false,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return configSync;
|
||||
}
|
||||
|
||||
describe("compatibility: Customisation Sync paths", () => {
|
||||
it.each([
|
||||
[".obsidian/app.json", "CONFIG"],
|
||||
[".obsidian/themes/minimal/manifest.json", "THEME"],
|
||||
[".obsidian/themes/minimal/theme.css", "THEME"],
|
||||
[".obsidian/snippets/example.css", "SNIPPET"],
|
||||
[".obsidian/plugins/example/manifest.json", "PLUGIN_MAIN"],
|
||||
[".obsidian/plugins/example/main.js", "PLUGIN_MAIN"],
|
||||
[".obsidian/plugins/example/styles.css", "PLUGIN_MAIN"],
|
||||
[".obsidian/plugins/example/data.json", "PLUGIN_DATA"],
|
||||
[".obsidian/plugins/example/other.json", "PLUGIN_ETC"],
|
||||
[".obsidian/workspace", ""],
|
||||
["notes/example.json", "CONFIG"],
|
||||
])("classifies %s as %s", (path, expected) => {
|
||||
expect(createConfigSync().testing.getFileCategory(path)).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps other plug-in files outside V1 and disabled plug-in-extra synchronisation", () => {
|
||||
const v1 = createConfigSync({ useV2: false });
|
||||
const withoutPluginEtc = createConfigSync({ usePluginEtc: false });
|
||||
const path = ".obsidian/plugins/example/other.json";
|
||||
|
||||
expect(v1.testing.getFileCategory(path)).toBe("");
|
||||
expect(withoutPluginEtc.testing.getFileCategory(path)).toBe("");
|
||||
});
|
||||
|
||||
it("recognises only classified files below the Obsidian configuration directory", () => {
|
||||
const configSync = createConfigSync();
|
||||
|
||||
expect(configSync.testing.isTargetPath(".obsidian/app.json")).toBe(true);
|
||||
expect(configSync.testing.isTargetPath(".obsidian/plugins/example/main.js")).toBe(true);
|
||||
expect(configSync.testing.isTargetPath(".obsidian/workspace")).toBe(false);
|
||||
expect(configSync.testing.isTargetPath("notes/example.json")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".obsidian/app.json", "ix:device-a/CONFIG/app.json.md"],
|
||||
[".obsidian/snippets/example.css", "ix:device-a/SNIPPET/example.css.md"],
|
||||
[".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example.md"],
|
||||
[".obsidian/plugins/example/data.json", "ix:device-a/PLUGIN_DATA/example.md"],
|
||||
])("creates the V1 document path for %s", (path, expected) => {
|
||||
expect(createConfigSync().testing.filenameToUnifiedKey(path)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".obsidian/app.json", "ix:device-a/CONFIG/app.json%app.json"],
|
||||
[".obsidian/snippets/example.css", "ix:device-a/SNIPPET/example.css%example.css"],
|
||||
[".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example%main.js"],
|
||||
[".obsidian/plugins/example/data.json", "ix:device-a/PLUGIN_DATA/example%data.json"],
|
||||
])("creates the V2 document path for %s", (path, expected) => {
|
||||
expect(createConfigSync().testing.filenameWithUnifiedKey(path)).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses an explicit device name when supplied", () => {
|
||||
const configSync = createConfigSync();
|
||||
|
||||
expect(configSync.testing.filenameToUnifiedKey(".obsidian/app.json", "device-b")).toBe(
|
||||
"ix:device-b/CONFIG/app.json.md"
|
||||
);
|
||||
expect(configSync.testing.filenameWithUnifiedKey(".obsidian/app.json", "device-b")).toBe(
|
||||
"ix:device-b/CONFIG/app.json%app.json"
|
||||
);
|
||||
expect(configSync.testing.unifiedKeyPrefixOfTerminal("device-b")).toBe("ix:device-b/");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,10 @@ function createConfigSync(options: { ready?: boolean; suspended?: boolean; enabl
|
||||
_isMainReady: vi.fn(() => options.ready ?? true),
|
||||
_isMainSuspended: vi.fn(() => options.suspended ?? false),
|
||||
isThisModuleEnabled: vi.fn(() => options.enabled ?? true),
|
||||
pathOperations: {
|
||||
isTargetPath: vi.fn((path: FilePath) => path == PATH),
|
||||
filenameToUnifiedKey: vi.fn(() => "ix:device-a/PLUGIN_DATA/example.md"),
|
||||
},
|
||||
recentProcessedInternalFiles,
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -81,9 +81,12 @@ function createConfigSync(options: {
|
||||
ownsLocalDocument,
|
||||
}),
|
||||
scanInternalFiles: vi.fn(async () => options.localFiles ?? []),
|
||||
filenameToUnifiedKey: vi.fn(() => V1_PATH),
|
||||
filenameWithUnifiedKey: vi.fn(() => V2_PATH),
|
||||
unifiedKeyPrefixOfTerminal: vi.fn(() => "ix:device-a/"),
|
||||
pathOperations: {
|
||||
isTargetPath: vi.fn(() => true),
|
||||
filenameToUnifiedKey: vi.fn(() => V1_PATH),
|
||||
filenameWithUnifiedKey: vi.fn(() => V2_PATH),
|
||||
unifiedKeyPrefixOfTerminal: vi.fn(() => "ix:device-a/"),
|
||||
},
|
||||
getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path),
|
||||
storeCustomizationFiles,
|
||||
storeCustomisationFileV2,
|
||||
|
||||
@@ -44,19 +44,11 @@ import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import {
|
||||
createCustomisationSyncDevicePrefix,
|
||||
createCustomisationSyncV1DocumentPath,
|
||||
createCustomisationSyncV2DocumentPath,
|
||||
getCustomisationSyncFileCategory,
|
||||
isCustomisationSyncTargetPath,
|
||||
parseCustomisationSyncV2DocumentPath,
|
||||
} from "./customisationSyncPaths.ts";
|
||||
import { parseCustomisationSyncV2DocumentPath } from "./customisationSyncPaths.ts";
|
||||
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
|
||||
import type {
|
||||
CustomisationSyncDialogView,
|
||||
CustomisationSyncUIControl,
|
||||
CustomisationSyncFileCategory,
|
||||
CustomisationSyncServiceHandlers,
|
||||
CustomisationSyncTestingView,
|
||||
IPluginDataExDisplay,
|
||||
@@ -83,6 +75,10 @@ import {
|
||||
} from "./customisationSyncReadOperations.ts";
|
||||
import { CustomisationSyncCatalogueState } from "./customisationSyncCatalogueState.ts";
|
||||
import { CustomisationSyncRecentEventDeduplicator } from "./customisationSyncRecentEventDeduplicator.ts";
|
||||
import {
|
||||
createCustomisationSyncPathOperations,
|
||||
type CustomisationSyncPathOperations,
|
||||
} from "./customisationSyncPathOperations.ts";
|
||||
|
||||
export type { PluginDataEx, PluginDataExFile } from "./customisationSyncCodec.ts";
|
||||
export type {
|
||||
@@ -167,6 +163,7 @@ export type CustomisationSyncContextDependencies = OptionalFileSyncFileTreeDepen
|
||||
|
||||
export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
private readonly dependencies: CustomisationSyncContextDependencies;
|
||||
private readonly pathOperations: CustomisationSyncPathOperations;
|
||||
private readonly catalogueState = new CustomisationSyncCatalogueState();
|
||||
private readonly recentProcessedInternalFiles = new CustomisationSyncRecentEventDeduplicator();
|
||||
private serviceHandlersView: CustomisationSyncServiceHandlers | undefined;
|
||||
@@ -186,6 +183,12 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
|
||||
constructor(dependencies: CustomisationSyncContextDependencies) {
|
||||
this.dependencies = dependencies;
|
||||
this.pathOperations = createCustomisationSyncPathOperations({
|
||||
getConfigDir: () => dependencies.getConfigDir(),
|
||||
getUseV2: () => dependencies.getSettings().usePluginSyncV2,
|
||||
getUsePluginEtc: () => dependencies.getSettings().usePluginEtc,
|
||||
getDeviceAndVaultName: () => dependencies.getDeviceAndVaultName(),
|
||||
});
|
||||
this.periodicPluginSweepProcessor = dependencies.createPeriodicProcessor(
|
||||
async () => await this.scanAllConfigFiles(false)
|
||||
);
|
||||
@@ -224,14 +227,6 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
configDir: this.configDir,
|
||||
scanInternalFiles: async () => await this.scanInternalFiles(),
|
||||
scanAllConfigFiles: async (showMessage: boolean) => await this.scanAllConfigFiles(showMessage),
|
||||
getFileCategory: (filePath: string) => this.getFileCategory(filePath),
|
||||
isTargetPath: (filePath: string) => this.isTargetPath(filePath),
|
||||
filenameToUnifiedKey: (path: string, termOverride?: string) =>
|
||||
this.filenameToUnifiedKey(path, termOverride),
|
||||
filenameWithUnifiedKey: (path: string, termOverride?: string) =>
|
||||
this.filenameWithUnifiedKey(path, termOverride),
|
||||
unifiedKeyPrefixOfTerminal: (termOverride?: string) =>
|
||||
this.unifiedKeyPrefixOfTerminal(termOverride),
|
||||
storeCustomizationFiles: async (path: FilePath, termOverride?: string) =>
|
||||
await this.storeCustomizationFiles(path, termOverride),
|
||||
deleteConfigOnDatabase: async (path: FilePathWithPrefix, forceWrite?: boolean) =>
|
||||
@@ -362,7 +357,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
async duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise<void> {
|
||||
const path = `${this.configDir}/${data.files[0].filename}` as FilePath;
|
||||
await this.storeCustomizationFiles(path, deviceName);
|
||||
await this.updatePluginList(false, this.filenameToUnifiedKey(path, deviceName));
|
||||
await this.updatePluginList(false, this.pathOperations.filenameToUnifiedKey(path, deviceName));
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@@ -376,20 +371,6 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
this.dependencies.hideConfigurationNotice();
|
||||
}
|
||||
|
||||
private getFileCategory(filePath: string): CustomisationSyncFileCategory {
|
||||
return getCustomisationSyncFileCategory(filePath, {
|
||||
configDir: this.configDir,
|
||||
useV2: this.useV2,
|
||||
usePluginEtc: this.useSyncPluginEtc,
|
||||
});
|
||||
}
|
||||
private isTargetPath(filePath: string): boolean {
|
||||
return isCustomisationSyncTargetPath(filePath, {
|
||||
configDir: this.configDir,
|
||||
useV2: this.useV2,
|
||||
usePluginEtc: this.useSyncPluginEtc,
|
||||
});
|
||||
}
|
||||
private async onDatabaseInitialised(showNotice: boolean) {
|
||||
if (!this.isThisModuleEnabled()) return true;
|
||||
try {
|
||||
@@ -504,29 +485,6 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
}
|
||||
).startPipeline();
|
||||
|
||||
private filenameToUnifiedKey(path: string, termOverRide?: string): FilePathWithPrefix {
|
||||
const term = termOverRide || this.dependencies.getDeviceAndVaultName();
|
||||
return createCustomisationSyncV1DocumentPath(path, term, {
|
||||
configDir: this.configDir,
|
||||
useV2: this.useV2,
|
||||
usePluginEtc: this.useSyncPluginEtc,
|
||||
});
|
||||
}
|
||||
|
||||
private filenameWithUnifiedKey(path: string, termOverRide?: string): FilePathWithPrefix {
|
||||
const term = termOverRide || this.dependencies.getDeviceAndVaultName();
|
||||
return createCustomisationSyncV2DocumentPath(path, term, {
|
||||
configDir: this.configDir,
|
||||
useV2: this.useV2,
|
||||
usePluginEtc: this.useSyncPluginEtc,
|
||||
});
|
||||
}
|
||||
|
||||
private unifiedKeyPrefixOfTerminal(termOverRide?: string): string {
|
||||
const term = termOverRide || this.dependencies.getDeviceAndVaultName();
|
||||
return createCustomisationSyncDevicePrefix(term);
|
||||
}
|
||||
|
||||
private async createPluginDataExFileV2(
|
||||
unifiedPathV2: FilePathWithPrefix,
|
||||
loaded?: LoadedEntry
|
||||
@@ -573,7 +531,6 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
private async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
||||
try {
|
||||
this.catalogueState.beginUpdate();
|
||||
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
|
||||
const { pathV1 } = parseCustomisationSyncV2DocumentPath(unifiedFilenameWithKey);
|
||||
|
||||
const oldEntry = this.catalogueState.findPlugin(pathV1);
|
||||
@@ -957,7 +914,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
}
|
||||
|
||||
private async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
|
||||
const vf = this.filenameWithUnifiedKey(path, term);
|
||||
const vf = this.pathOperations.filenameWithUnifiedKey(path, term);
|
||||
return await serialized(`plugin-${vf}`, async () => {
|
||||
const prefixedFileName = vf;
|
||||
|
||||
@@ -1026,7 +983,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
|
||||
fireAndForget(() => this.updatePluginListV2(false, this.pathOperations.filenameWithUnifiedKey(path)));
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
@@ -1044,11 +1001,11 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
if (this.useV2) {
|
||||
return await this.storeCustomisationFileV2(path, term);
|
||||
}
|
||||
const vf = this.filenameToUnifiedKey(path, term);
|
||||
const vf = this.pathOperations.filenameToUnifiedKey(path, term);
|
||||
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
|
||||
|
||||
return await serialized(`plugin-${vf}`, async () => {
|
||||
const category = this.getFileCategory(path);
|
||||
const category = this.pathOperations.getFileCategory(path);
|
||||
let mtime = 0;
|
||||
let fileTargets = [] as FilePath[];
|
||||
// let savePath = "";
|
||||
@@ -1057,7 +1014,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
? path.split("/").reverse()[0]
|
||||
: path.split("/").reverse()[1];
|
||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||
const prefixedFileName = this.filenameToUnifiedKey(path, term);
|
||||
const prefixedFileName = this.pathOperations.filenameToUnifiedKey(path, term);
|
||||
const id = await this.path2id(prefixedFileName);
|
||||
const dt: PluginDataEx = {
|
||||
category: category,
|
||||
@@ -1187,7 +1144,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
if (!this._isMainReady()) return false;
|
||||
if (this._isMainSuspended()) return false;
|
||||
if (!this.isThisModuleEnabled()) return false;
|
||||
if (!this.isTargetPath(path)) return false;
|
||||
if (!this.pathOperations.isTargetPath(path)) return false;
|
||||
if (!this.dependencies.ownsLocalFile(path)) return false;
|
||||
const stat = await this.storageAccess.statHidden(path);
|
||||
// Make sure that target is a file.
|
||||
@@ -1202,7 +1159,7 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
return true;
|
||||
}
|
||||
// To prevent saving half-collected file sets.
|
||||
const keySchedule = this.filenameToUnifiedKey(path);
|
||||
const keySchedule = this.pathOperations.filenameToUnifiedKey(path);
|
||||
scheduleTask(keySchedule, 100, async () => {
|
||||
await this.storeCustomizationFiles(path);
|
||||
});
|
||||
@@ -1223,10 +1180,16 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
const filesAll = await this.scanInternalFiles();
|
||||
if (this.useV2) {
|
||||
const filesAllUnified = filesAll
|
||||
.filter((e) => this.isTargetPath(e))
|
||||
.map((e) => [this.filenameWithUnifiedKey(e, term), e] as [FilePathWithPrefix, FilePath]);
|
||||
.filter((e) => this.pathOperations.isTargetPath(e))
|
||||
.map(
|
||||
(e) =>
|
||||
[this.pathOperations.filenameWithUnifiedKey(e, term), e] as [
|
||||
FilePathWithPrefix,
|
||||
FilePath,
|
||||
]
|
||||
);
|
||||
const localFileMap = new Map(filesAllUnified.map((e) => [e[0], e[1]]));
|
||||
const prefix = this.unifiedKeyPrefixOfTerminal(term);
|
||||
const prefix = this.pathOperations.unifiedKeyPrefixOfTerminal(term);
|
||||
const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, {
|
||||
include_docs: true,
|
||||
});
|
||||
@@ -1279,8 +1242,8 @@ export class CustomisationSyncContext implements CustomisationSyncDialogView {
|
||||
fireAndForget(() => this.updatePluginList(false));
|
||||
} else {
|
||||
const files = filesAll
|
||||
.filter((e) => this.isTargetPath(e))
|
||||
.map((e) => ({ key: this.filenameToUnifiedKey(e), file: e }));
|
||||
.filter((e) => this.pathOperations.isTargetPath(e))
|
||||
.map((e) => ({ key: this.pathOperations.filenameToUnifiedKey(e), file: e }));
|
||||
const virtualPathsOfLocalFiles = [...new Set(files.map((e) => e.key))];
|
||||
const filesOnDB = (
|
||||
(
|
||||
|
||||
@@ -130,7 +130,9 @@ describe("CustomisationSyncContext dialogue view", () => {
|
||||
const updatePluginList = vi.fn(async () => undefined);
|
||||
Object.assign(configSync, {
|
||||
compareUsingDisplayData,
|
||||
filenameToUnifiedKey: vi.fn(() => "ix:device-b/PLUGIN_DATA/example.md"),
|
||||
pathOperations: {
|
||||
filenameToUnifiedKey: vi.fn(() => "ix:device-b/PLUGIN_DATA/example.md"),
|
||||
},
|
||||
storeCustomizationFiles,
|
||||
updatePluginList,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import {
|
||||
createCustomisationSyncDevicePrefix,
|
||||
createCustomisationSyncV1DocumentPath,
|
||||
createCustomisationSyncV2DocumentPath,
|
||||
getCustomisationSyncFileCategory,
|
||||
isCustomisationSyncTargetPath,
|
||||
type CustomisationSyncFileCategory,
|
||||
type CustomisationSyncPathOptions,
|
||||
} from "./customisationSyncPaths.ts";
|
||||
|
||||
/** Live projections needed to derive Customisation Sync paths. */
|
||||
export type CustomisationSyncPathOperationsDependencies = Readonly<{
|
||||
getConfigDir: () => string;
|
||||
getUseV2: () => boolean;
|
||||
getUsePluginEtc: () => boolean;
|
||||
getDeviceAndVaultName: () => string;
|
||||
}>;
|
||||
|
||||
/** Path operations exposed to the Customisation Sync context. */
|
||||
export type CustomisationSyncPathOperations = Readonly<{
|
||||
getFileCategory(filePath: string): CustomisationSyncFileCategory;
|
||||
isTargetPath(filePath: string): boolean;
|
||||
filenameToUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix;
|
||||
filenameWithUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix;
|
||||
unifiedKeyPrefixOfTerminal(termOverride?: string): FilePathWithPrefix;
|
||||
}>;
|
||||
|
||||
function getPathOptions(dependencies: CustomisationSyncPathOperationsDependencies): CustomisationSyncPathOptions {
|
||||
return {
|
||||
configDir: dependencies.getConfigDir(),
|
||||
useV2: dependencies.getUseV2(),
|
||||
usePluginEtc: dependencies.getUsePluginEtc(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCustomisationSyncPathOperations(
|
||||
dependencies: CustomisationSyncPathOperationsDependencies
|
||||
): CustomisationSyncPathOperations {
|
||||
return Object.freeze({
|
||||
getFileCategory: (filePath: string) => getCustomisationSyncFileCategory(filePath, getPathOptions(dependencies)),
|
||||
isTargetPath: (filePath: string) => isCustomisationSyncTargetPath(filePath, getPathOptions(dependencies)),
|
||||
filenameToUnifiedKey: (path: string, termOverride?: string) =>
|
||||
createCustomisationSyncV1DocumentPath(
|
||||
path,
|
||||
termOverride || dependencies.getDeviceAndVaultName(),
|
||||
getPathOptions(dependencies)
|
||||
),
|
||||
filenameWithUnifiedKey: (path: string, termOverride?: string) =>
|
||||
createCustomisationSyncV2DocumentPath(
|
||||
path,
|
||||
termOverride || dependencies.getDeviceAndVaultName(),
|
||||
getPathOptions(dependencies)
|
||||
),
|
||||
unifiedKeyPrefixOfTerminal: (termOverride?: string) =>
|
||||
createCustomisationSyncDevicePrefix(termOverride || dependencies.getDeviceAndVaultName()),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createCustomisationSyncPathOperations,
|
||||
type CustomisationSyncPathOperationsDependencies,
|
||||
} from "./customisationSyncPathOperations.ts";
|
||||
|
||||
type PathState = {
|
||||
configDir: string;
|
||||
useV2: boolean;
|
||||
usePluginEtc: boolean;
|
||||
deviceAndVaultName: string;
|
||||
};
|
||||
|
||||
function createOperations(state: PathState) {
|
||||
const dependencies: CustomisationSyncPathOperationsDependencies = {
|
||||
getConfigDir: () => state.configDir,
|
||||
getUseV2: () => state.useV2,
|
||||
getUsePluginEtc: () => state.usePluginEtc,
|
||||
getDeviceAndVaultName: () => state.deviceAndVaultName,
|
||||
};
|
||||
return createCustomisationSyncPathOperations(dependencies);
|
||||
}
|
||||
|
||||
describe("Customisation Sync path operations", () => {
|
||||
it("reads category and target settings through live getters", () => {
|
||||
const state: PathState = {
|
||||
configDir: ".obsidian",
|
||||
useV2: true,
|
||||
usePluginEtc: true,
|
||||
deviceAndVaultName: "device-a",
|
||||
};
|
||||
const operations = createOperations(state);
|
||||
const extraPluginFile = ".obsidian/plugins/example/settings.json";
|
||||
|
||||
expect(operations.getFileCategory(extraPluginFile)).toBe("PLUGIN_ETC");
|
||||
expect(operations.isTargetPath(extraPluginFile)).toBe(true);
|
||||
|
||||
state.useV2 = false;
|
||||
expect(operations.getFileCategory(extraPluginFile)).toBe("");
|
||||
expect(operations.isTargetPath(extraPluginFile)).toBe(false);
|
||||
|
||||
state.useV2 = true;
|
||||
state.usePluginEtc = false;
|
||||
expect(operations.getFileCategory(extraPluginFile)).toBe("");
|
||||
|
||||
state.configDir = ".config";
|
||||
expect(operations.isTargetPath(extraPluginFile)).toBe(false);
|
||||
expect(operations.isTargetPath(".config/plugins/example/settings.json")).toBe(false);
|
||||
});
|
||||
|
||||
it("derives V1, V2, and device-prefix paths from the current term and settings", () => {
|
||||
const state: PathState = {
|
||||
configDir: ".obsidian",
|
||||
useV2: true,
|
||||
usePluginEtc: true,
|
||||
deviceAndVaultName: "device-a",
|
||||
};
|
||||
const operations = createOperations(state);
|
||||
const path = ".obsidian/plugins/example/main.js";
|
||||
|
||||
expect(operations.filenameToUnifiedKey(path)).toBe("ix:device-a/PLUGIN_MAIN/example.md");
|
||||
expect(operations.filenameWithUnifiedKey(path)).toBe("ix:device-a/PLUGIN_MAIN/example%main.js");
|
||||
expect(operations.unifiedKeyPrefixOfTerminal()).toBe("ix:device-a/");
|
||||
|
||||
state.deviceAndVaultName = "device-b";
|
||||
expect(operations.filenameToUnifiedKey(path)).toBe("ix:device-b/PLUGIN_MAIN/example.md");
|
||||
expect(operations.filenameWithUnifiedKey(path)).toBe("ix:device-b/PLUGIN_MAIN/example%main.js");
|
||||
expect(operations.unifiedKeyPrefixOfTerminal()).toBe("ix:device-b/");
|
||||
});
|
||||
|
||||
it("keeps the existing override fallback semantics", () => {
|
||||
const state: PathState = {
|
||||
configDir: ".obsidian",
|
||||
useV2: true,
|
||||
usePluginEtc: true,
|
||||
deviceAndVaultName: "device-a",
|
||||
};
|
||||
const operations = createOperations(state);
|
||||
const path = ".obsidian/app.json";
|
||||
|
||||
expect(operations.filenameToUnifiedKey(path, "device-b")).toBe("ix:device-b/CONFIG/app.json.md");
|
||||
expect(operations.filenameWithUnifiedKey(path, "device-b")).toBe("ix:device-b/CONFIG/app.json%app.json");
|
||||
expect(operations.unifiedKeyPrefixOfTerminal("device-b")).toBe("ix:device-b/");
|
||||
|
||||
expect(operations.filenameToUnifiedKey(path, "")).toBe("ix:device-a/CONFIG/app.json.md");
|
||||
expect(operations.filenameWithUnifiedKey(path, "")).toBe("ix:device-a/CONFIG/app.json%app.json");
|
||||
expect(operations.unifiedKeyPrefixOfTerminal("")).toBe("ix:device-a/");
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import type PouchDB from "pouchdb-core";
|
||||
import type { Readable } from "svelte/store";
|
||||
|
||||
import type { PluginDataExFile } from "./customisationSyncCodec.ts";
|
||||
import type { CustomisationSyncFileCategory } from "./customisationSyncPaths.ts";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import type { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
|
||||
|
||||
@@ -67,11 +66,6 @@ export interface CustomisationSyncTestingView {
|
||||
readonly configDir: string;
|
||||
scanInternalFiles(): Promise<FilePath[]>;
|
||||
scanAllConfigFiles(showMessage: boolean): Promise<void>;
|
||||
getFileCategory(filePath: string): CustomisationSyncFileCategory;
|
||||
isTargetPath(filePath: string): boolean;
|
||||
filenameToUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix;
|
||||
filenameWithUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix;
|
||||
unifiedKeyPrefixOfTerminal(termOverride?: string): string;
|
||||
storeCustomizationFiles(path: FilePath, termOverride?: string): Promise<unknown>;
|
||||
deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite?: boolean): Promise<boolean>;
|
||||
createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix): PluginDataExDisplayV2 | undefined;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { FilePath, ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const HIDDEN_FILE_NOTIFICATION_TASK = "notify-config-change";
|
||||
const HIDDEN_FILE_NOTIFICATION_DELAY_MS = 1000;
|
||||
|
||||
export type HiddenFileSyncChangeNotifierSettings = Pick<ObsidianLiveSyncSettings, "suppressNotifyHiddenFilesChange">;
|
||||
|
||||
export type HiddenFileSyncChangeNotifierTaskScheduler = (
|
||||
key: string,
|
||||
timeout: number,
|
||||
operation: () => Promise<unknown> | void
|
||||
) => void;
|
||||
|
||||
export type HiddenFileSyncChangeNotifierDependencies = {
|
||||
getSettings(): HiddenFileSyncChangeNotifierSettings;
|
||||
getConfigDir(): string;
|
||||
scheduleTask: HiddenFileSyncChangeNotifierTaskScheduler;
|
||||
cancelTask(key: string): void;
|
||||
showConfigurationChangeNotice(updatedFolders: readonly string[]): void;
|
||||
hideConfigurationChangeNotice(): void;
|
||||
};
|
||||
|
||||
export type HiddenFileSyncChangeNotifier = {
|
||||
queueNotification(path: FilePath): void;
|
||||
/** Compatibility seam used by the real-Obsidian Hidden File Sync fixture. */
|
||||
showConfigurationChangeNotice(updatedFolders: readonly string[]): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
class HiddenFileSyncChangeNotifierOwner implements HiddenFileSyncChangeNotifier {
|
||||
private readonly queuedNotificationFiles = new Set<string>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly dependencies: HiddenFileSyncChangeNotifierDependencies) {}
|
||||
|
||||
queueNotification(path: FilePath): void {
|
||||
if (this.disposed) return;
|
||||
if (this.dependencies.getSettings().suppressNotifyHiddenFilesChange) return;
|
||||
|
||||
const configDir = this.dependencies.getConfigDir();
|
||||
if (!path.startsWith(configDir)) return;
|
||||
|
||||
const folder = path.split("/").slice(0, -1).join("/");
|
||||
this.queuedNotificationFiles.add(folder);
|
||||
this.dependencies.scheduleTask(HIDDEN_FILE_NOTIFICATION_TASK, HIDDEN_FILE_NOTIFICATION_DELAY_MS, () => {
|
||||
this.flush();
|
||||
});
|
||||
}
|
||||
|
||||
showConfigurationChangeNotice(updatedFolders: readonly string[]): void {
|
||||
this.queuedNotificationFiles.clear();
|
||||
for (const folder of updatedFolders) {
|
||||
this.queuedNotificationFiles.add(folder);
|
||||
}
|
||||
this.flush();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.queuedNotificationFiles.clear();
|
||||
this.dependencies.cancelTask(HIDDEN_FILE_NOTIFICATION_TASK);
|
||||
this.dependencies.hideConfigurationChangeNotice();
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
const updatedFolders = [...this.queuedNotificationFiles];
|
||||
this.queuedNotificationFiles.clear();
|
||||
if (this.disposed) return;
|
||||
this.dependencies.showConfigurationChangeNotice(updatedFolders);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHiddenFileSyncChangeNotifier(
|
||||
dependencies: HiddenFileSyncChangeNotifierDependencies
|
||||
): HiddenFileSyncChangeNotifier {
|
||||
return new HiddenFileSyncChangeNotifierOwner(dependencies);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import {
|
||||
createHiddenFileSyncChangeNotifier,
|
||||
type HiddenFileSyncChangeNotifierDependencies,
|
||||
} from "./hiddenFileSyncChangeNotifier.ts";
|
||||
|
||||
type ScheduledOperation = {
|
||||
key: string;
|
||||
timeout: number;
|
||||
operation: () => Promise<unknown> | void;
|
||||
};
|
||||
|
||||
function createFixture(overrides: Partial<HiddenFileSyncChangeNotifierDependencies> = {}): {
|
||||
notifier: ReturnType<typeof createHiddenFileSyncChangeNotifier>;
|
||||
scheduled: ScheduledOperation[];
|
||||
settings: { suppressNotifyHiddenFilesChange: boolean };
|
||||
configDir: { value: string };
|
||||
showConfigurationChangeNotice: ReturnType<typeof vi.fn>;
|
||||
hideConfigurationChangeNotice: ReturnType<typeof vi.fn>;
|
||||
scheduleTask: ReturnType<typeof vi.fn>;
|
||||
cancelTask: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const scheduled: ScheduledOperation[] = [];
|
||||
const settings = { suppressNotifyHiddenFilesChange: false };
|
||||
const configDir = { value: ".obsidian" };
|
||||
const showConfigurationChangeNotice = vi.fn();
|
||||
const hideConfigurationChangeNotice = vi.fn();
|
||||
const scheduleTask = vi.fn<HiddenFileSyncChangeNotifierDependencies["scheduleTask"]>((key, timeout, operation) => {
|
||||
scheduled.push({ key, timeout, operation });
|
||||
});
|
||||
const cancelTask = vi.fn<HiddenFileSyncChangeNotifierDependencies["cancelTask"]>();
|
||||
const dependencies: HiddenFileSyncChangeNotifierDependencies = {
|
||||
getSettings: () => settings,
|
||||
getConfigDir: () => configDir.value,
|
||||
scheduleTask,
|
||||
cancelTask,
|
||||
showConfigurationChangeNotice,
|
||||
hideConfigurationChangeNotice,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
notifier: createHiddenFileSyncChangeNotifier(dependencies),
|
||||
scheduled,
|
||||
settings,
|
||||
configDir,
|
||||
showConfigurationChangeNotice,
|
||||
hideConfigurationChangeNotice,
|
||||
scheduleTask,
|
||||
cancelTask,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Hidden File Sync change notifier", () => {
|
||||
it("queues distinct parent folders and flushes them in insertion order", () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
fixture.notifier.queueNotification(".obsidian/plugins/alpha/data.json" as FilePath);
|
||||
fixture.notifier.queueNotification(".obsidian/plugins/beta/data.json" as FilePath);
|
||||
fixture.notifier.queueNotification(".obsidian/plugins/alpha/main.js" as FilePath);
|
||||
|
||||
expect(fixture.scheduleTask).toHaveBeenCalledTimes(3);
|
||||
expect(fixture.scheduleTask).toHaveBeenLastCalledWith("notify-config-change", 1000, expect.any(Function));
|
||||
|
||||
fixture.scheduled[fixture.scheduled.length - 1]?.operation();
|
||||
|
||||
expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith([
|
||||
".obsidian/plugins/alpha",
|
||||
".obsidian/plugins/beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses live suppression and configuration-directory dependencies", () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
fixture.settings.suppressNotifyHiddenFilesChange = true;
|
||||
fixture.notifier.queueNotification(".obsidian/plugins/suppressed/data.json" as FilePath);
|
||||
fixture.settings.suppressNotifyHiddenFilesChange = false;
|
||||
fixture.notifier.queueNotification("other/plugins/outside/data.json" as FilePath);
|
||||
fixture.configDir.value = "other";
|
||||
fixture.notifier.queueNotification("other/plugins/inside/data.json" as FilePath);
|
||||
|
||||
expect(fixture.scheduled).toHaveLength(1);
|
||||
fixture.scheduled[0]?.operation();
|
||||
|
||||
expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith(["other/plugins/inside"]);
|
||||
});
|
||||
|
||||
it("clears the batch before displaying it", () => {
|
||||
const fixture = createFixture();
|
||||
fixture.showConfigurationChangeNotice.mockImplementation(() => {
|
||||
fixture.notifier.queueNotification(".obsidian/plugins/new/data.json" as FilePath);
|
||||
});
|
||||
fixture.notifier.queueNotification(".obsidian/plugins/old/data.json" as FilePath);
|
||||
|
||||
fixture.scheduled[0]?.operation();
|
||||
expect(fixture.showConfigurationChangeNotice).toHaveBeenNthCalledWith(1, [".obsidian/plugins/old"]);
|
||||
|
||||
fixture.scheduled[1]?.operation();
|
||||
expect(fixture.showConfigurationChangeNotice).toHaveBeenNthCalledWith(2, [".obsidian/plugins/new"]);
|
||||
});
|
||||
|
||||
it("supports the immediate fixture seam without scheduling another task", () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
fixture.notifier.showConfigurationChangeNotice([
|
||||
".obsidian/plugins/alpha",
|
||||
".obsidian/plugins/beta",
|
||||
".obsidian/plugins/alpha",
|
||||
]);
|
||||
|
||||
expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith([
|
||||
".obsidian/plugins/alpha",
|
||||
".obsidian/plugins/beta",
|
||||
]);
|
||||
expect(fixture.scheduleTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels pending work, hides the Notice, and ignores later work on disposal", () => {
|
||||
const fixture = createFixture();
|
||||
fixture.notifier.queueNotification(".obsidian/plugins/example/data.json" as FilePath);
|
||||
|
||||
fixture.notifier.dispose();
|
||||
fixture.notifier.dispose();
|
||||
fixture.scheduled[0]?.operation();
|
||||
fixture.notifier.queueNotification(".obsidian/plugins/later/data.json" as FilePath);
|
||||
|
||||
expect(fixture.cancelTask).toHaveBeenCalledOnce();
|
||||
expect(fixture.cancelTask).toHaveBeenCalledWith("notify-config-change");
|
||||
expect(fixture.hideConfigurationChangeNotice).toHaveBeenCalledOnce();
|
||||
expect(fixture.showConfigurationChangeNotice).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleTask).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
+8
-7
@@ -37,16 +37,17 @@ function createContext(processedFiles = new Map()) {
|
||||
return { context, hideConfigurationChangeNotice, periodicProcessor, publishActivity };
|
||||
}
|
||||
|
||||
describe("HiddenFileSyncContext state ownership", () => {
|
||||
it("owns caches, concurrency controls, processors, and conflict owners per context instance", () => {
|
||||
describe("HiddenFileSyncContext ownership and start-up lifecycle", () => {
|
||||
it("creates each stateful capability owner per context instance", () => {
|
||||
const first = createContext();
|
||||
const second = createContext();
|
||||
|
||||
getPrivate<Set<string>>(first.context, "queuedNotificationFiles").add(".obsidian/plugins/first");
|
||||
getPrivate<Map<string, unknown[]>>(first.context, "cacheFileRegExps").set("first", []);
|
||||
|
||||
expect(getPrivate<Set<string>>(second.context, "queuedNotificationFiles").size).toBe(0);
|
||||
expect(getPrivate<Map<string, unknown[]>>(second.context, "cacheFileRegExps")).toEqual(new Map());
|
||||
expect(getPrivate<unknown>(first.context, "pathAdmission")).not.toBe(
|
||||
getPrivate<unknown>(second.context, "pathAdmission")
|
||||
);
|
||||
expect(getPrivate<unknown>(first.context, "changeNotifier")).not.toBe(
|
||||
getPrivate<unknown>(second.context, "changeNotifier")
|
||||
);
|
||||
expect(first.context.testing.conflictResolution).not.toBe(second.context.testing.conflictResolution);
|
||||
expect(getProcessedState(first.context)).not.toBe(getProcessedState(second.context));
|
||||
expect(getPrivate<unknown>(first.context, "changeProcessor")).not.toBe(
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
configureHiddenFileSyncMode: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HiddenFileSyncContext } from "./hiddenFileSyncContext.ts";
|
||||
|
||||
const PATH = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
|
||||
function isTargetFile(context: HiddenFileSyncContext, path: FilePath): Promise<boolean> {
|
||||
return (context as unknown as { isTargetFile(path: FilePath): Promise<boolean> }).isTargetFile(path);
|
||||
}
|
||||
|
||||
function isTargetFileEligible(context: HiddenFileSyncContext, path: FilePath): Promise<boolean> {
|
||||
return (context as unknown as { isTargetFileEligible(path: FilePath): Promise<boolean> }).isTargetFileEligible(path);
|
||||
}
|
||||
|
||||
function createHiddenFileSync(
|
||||
options: { owned?: boolean; ignoredByIgnoreFile?: boolean; patternMatch?: boolean } = {}
|
||||
) {
|
||||
const ownsLocalFile = vi.fn(() => options.owned ?? true);
|
||||
const isIgnoredByIgnoreFile = vi.fn(async () => options.ignoredByIgnoreFile ?? false);
|
||||
const patternTest = vi.fn(() => options.patternMatch ?? true);
|
||||
const parseRegExpSettings = vi.fn(() => ({
|
||||
ignoreFilter: [],
|
||||
targetFilter: options.patternMatch === undefined ? [] : [{ test: patternTest }],
|
||||
}));
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: { ownsLocalFile, isIgnoredByIgnoreFile },
|
||||
parseRegExpSettings,
|
||||
});
|
||||
return { hiddenFileSync, isIgnoredByIgnoreFile, parseRegExpSettings, ownsLocalFile };
|
||||
}
|
||||
|
||||
describe("Hidden File Sync local-path admission", () => {
|
||||
it("checks composition ownership before Hidden File Sync filters", async () => {
|
||||
const { hiddenFileSync, parseRegExpSettings, ownsLocalFile } = createHiddenFileSync({ owned: false });
|
||||
|
||||
await expect(isTargetFile(hiddenFileSync, PATH)).resolves.toBe(false);
|
||||
expect(ownsLocalFile).toHaveBeenCalledWith(PATH);
|
||||
expect(parseRegExpSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps target patterns and ignore-file results as Hidden File Sync eligibility", async () => {
|
||||
const patternExcluded = createHiddenFileSync({ patternMatch: false });
|
||||
await expect(isTargetFile(patternExcluded.hiddenFileSync, PATH)).resolves.toBe(false);
|
||||
expect(patternExcluded.isIgnoredByIgnoreFile).not.toHaveBeenCalled();
|
||||
|
||||
const ignoreFileExcluded = createHiddenFileSync({ ignoredByIgnoreFile: true });
|
||||
await expect(isTargetFile(ignoreFileExcluded.hiddenFileSync, PATH)).resolves.toBe(false);
|
||||
expect(ignoreFileExcluded.isIgnoredByIgnoreFile).toHaveBeenCalledWith(PATH);
|
||||
|
||||
const admitted = createHiddenFileSync();
|
||||
await expect(isTargetFile(admitted.hiddenFileSync, PATH)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("evaluates eligibility without consulting the composition owner", async () => {
|
||||
const { hiddenFileSync, ownsLocalFile } = createHiddenFileSync({ owned: false });
|
||||
|
||||
await expect(isTargetFileEligible(hiddenFileSync, PATH)).resolves.toBe(true);
|
||||
expect(ownsLocalFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
type HiddenFileSyncTestingRebuild,
|
||||
type HiddenFileSyncTestingView,
|
||||
} from "./hiddenFileSyncViews.ts";
|
||||
import { isHiddenFileSyncPath, matchesHiddenFileSyncPatterns } from "./hiddenFileSyncPathPolicy.ts";
|
||||
import { describeHiddenFileSyncDocument, getHiddenFileSyncComparisonMTime } from "./hiddenFileSyncState.ts";
|
||||
import {
|
||||
collectOptionalFileSyncFiles,
|
||||
@@ -82,6 +81,14 @@ import {
|
||||
createHiddenFileSyncChangeProcessor,
|
||||
type HiddenFileSyncChangeProcessor,
|
||||
} from "./hiddenFileSyncChangeProcessor.ts";
|
||||
import {
|
||||
createHiddenFileSyncPathAdmission,
|
||||
type HiddenFileSyncPathAdmission,
|
||||
} from "./hiddenFileSyncPathAdmission.ts";
|
||||
import {
|
||||
createHiddenFileSyncChangeNotifier,
|
||||
type HiddenFileSyncChangeNotifier,
|
||||
} from "./hiddenFileSyncChangeNotifier.ts";
|
||||
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
|
||||
|
||||
export type HiddenFileSyncProgress = {
|
||||
@@ -90,8 +97,6 @@ export type HiddenFileSyncProgress = {
|
||||
done(message?: string): void;
|
||||
};
|
||||
|
||||
const HIDDEN_FILE_NOTIFICATION_TASK = "notify-config-change";
|
||||
|
||||
type HiddenFileSyncSettings = Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
| "syncInternalFiles"
|
||||
@@ -175,6 +180,8 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
private readonly databaseExtractionOperations: HiddenFileSyncDatabaseExtractionOperations;
|
||||
private readonly conflictResolution: HiddenFileSyncConflictResolution;
|
||||
private readonly changeProcessor: HiddenFileSyncChangeProcessor;
|
||||
private readonly pathAdmission: HiddenFileSyncPathAdmission;
|
||||
private readonly changeNotifier: HiddenFileSyncChangeNotifier;
|
||||
readonly serviceHandlers: HiddenFileSyncServiceHandlerView;
|
||||
readonly testing: HiddenFileSyncTestingView;
|
||||
readonly repair: HiddenFileSyncRepairView;
|
||||
@@ -184,6 +191,22 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
|
||||
constructor(dependencies: HiddenFileSyncContextDependencies) {
|
||||
this.dependencies = dependencies;
|
||||
this.pathAdmission = createHiddenFileSyncPathAdmission({
|
||||
getTargetPatternSource: () => dependencies.getSettings().syncInternalFilesTargetPatterns,
|
||||
getIgnorePatternSource: () => dependencies.getSettings().syncInternalFilesIgnorePatterns,
|
||||
getFileRegExp: (key) => dependencies.getFileRegExp(key),
|
||||
isIgnoredByIgnoreFile: async (path) => await dependencies.isIgnoredByIgnoreFile(path),
|
||||
ownsLocalFile: (path) => dependencies.ownsLocalFile(path),
|
||||
});
|
||||
this.changeNotifier = createHiddenFileSyncChangeNotifier({
|
||||
getSettings: () => dependencies.getSettings(),
|
||||
getConfigDir: () => dependencies.getConfigDir(),
|
||||
scheduleTask: (key, timeout, operation) => scheduleTask(key, timeout, operation),
|
||||
cancelTask: (key) => cancelTask(key),
|
||||
showConfigurationChangeNotice: (updatedFolders) =>
|
||||
dependencies.showConfigurationChangeNotice(updatedFolders),
|
||||
hideConfigurationChangeNotice: () => dependencies.hideConfigurationChangeNotice(),
|
||||
});
|
||||
this.processedState = createHiddenFileSyncProcessedState({
|
||||
getKeyValueDatabase: () => this.dependencies.getKeyValueDatabase(),
|
||||
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
|
||||
@@ -224,7 +247,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
await writeHiddenFileFromDatabase(dependencies, path, entry, force),
|
||||
deleteStorageFile: async (path) => await deleteHiddenFileFromStorage(dependencies, path),
|
||||
processedState: this.processedState,
|
||||
queueNotification: (path) => this.queueNotification(path),
|
||||
queueNotification: (path) => this.changeNotifier.queueNotification(path),
|
||||
log: (message, level, key) => dependencies.log(message, level, key),
|
||||
});
|
||||
this.conflictResolution = createHiddenFileSyncConflictResolution({
|
||||
@@ -299,7 +322,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
onDatabaseInitialised: async (showNotice) => await this.onDatabaseInitialised(showNotice),
|
||||
suspendExtraSync: async () => await this.suspendExtraSync(),
|
||||
configureOptionalSyncFeature: async (mode) => await this.configureOptionalSyncFeature(mode),
|
||||
isTargetFileEligible: async (path) => await this.isTargetFileEligible(path),
|
||||
isTargetFileEligible: async (path) => await this.pathAdmission.isTargetFileEligible(path),
|
||||
queueConflict: async (path) => await this.queueConflict(path),
|
||||
});
|
||||
this.testing = createHiddenFileSyncTestingView({
|
||||
@@ -312,13 +335,8 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
await this.initialiseInternalFileSync(direction, showMessage, targetFiles),
|
||||
conflictResolution: this.conflictResolution.testing,
|
||||
readFileWithInfo: async (path) => await readHiddenFileWithInfo(dependencies, path),
|
||||
showConfigurationChangeNotice: (updatedFolders) => {
|
||||
this.queuedNotificationFiles.clear();
|
||||
for (const folder of updatedFolders) {
|
||||
this.queuedNotificationFiles.add(folder);
|
||||
}
|
||||
this.notifyConfigChange();
|
||||
},
|
||||
showConfigurationChangeNotice: (updatedFolders) =>
|
||||
this.changeNotifier.showConfigurationChangeNotice(updatedFolders),
|
||||
interceptRebuildMerging: (interceptor) => {
|
||||
const previousHook = this.rebuildMergingHook;
|
||||
const runRebuild = async (showNotice: boolean, targetFiles?: FilePath[] | false) =>
|
||||
@@ -393,12 +411,10 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
this.periodicInternalFileScanProcessor?.disable();
|
||||
this.changeProcessor.dispose();
|
||||
this.conflictResolution.dispose();
|
||||
this.pathAdmission.dispose();
|
||||
this.changeNotifier.dispose();
|
||||
this.rebuildMergingHook = undefined;
|
||||
this.queuedNotificationFiles.clear();
|
||||
this.cacheFileRegExps.clear();
|
||||
cancelTask(HIDDEN_FILE_NOTIFICATION_TASK);
|
||||
this.dependencies.closeJsonConflictDialogs();
|
||||
this.dependencies.hideConfigurationChangeNotice();
|
||||
}
|
||||
|
||||
// The key-value database becomes available before this lifecycle callback.
|
||||
@@ -432,7 +448,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
}
|
||||
|
||||
updateSettingCache() {
|
||||
this.cacheFileRegExps.clear();
|
||||
this.pathAdmission.invalidatePatternCache();
|
||||
}
|
||||
|
||||
private isReady() {
|
||||
@@ -470,7 +486,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
? this.settings.syncInternalFilesInterval * 1000
|
||||
: 0
|
||||
);
|
||||
this.cacheFileRegExps.clear();
|
||||
this.pathAdmission.invalidatePatternCache();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -487,7 +503,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
|
||||
//system file
|
||||
const filename = this.getPath(doc);
|
||||
const unprefixedPath = stripAllPrefixes(filename);
|
||||
if (!(await this.isTargetFile(stripAllPrefixes(unprefixedPath)))) {
|
||||
if (!(await this.pathAdmission.isTargetFile(stripAllPrefixes(unprefixedPath)))) {
|
||||
this._log(
|
||||
`Skipped processing sync file:${unprefixedPath} (Not Hidden File Sync target)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -595,7 +611,7 @@ Offline Changed files: ${processFiles.length}`;
|
||||
forceWrite = false,
|
||||
includeDeleted = true
|
||||
): Promise<boolean | undefined> {
|
||||
if (!(await this.isTargetFile(path))) {
|
||||
if (!(await this.pathAdmission.isTargetFile(path))) {
|
||||
this._log(
|
||||
`Storage file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -619,44 +635,6 @@ Offline Changed files: ${processFiles.length}`;
|
||||
|
||||
// --> Database Event Functions
|
||||
|
||||
private readonly cacheFileRegExps = new Map<string, CustomRegExp[][]>();
|
||||
/**
|
||||
* Parses the regular expression settings for hidden file synchronization.
|
||||
* @returns An object containing the ignore and target filters.
|
||||
*/
|
||||
private parseRegExpSettings() {
|
||||
const regExpKey = `${this.settings.syncInternalFilesTargetPatterns}||${this.settings.syncInternalFilesIgnorePatterns}`;
|
||||
let ignoreFilter: CustomRegExp[];
|
||||
let targetFilter: CustomRegExp[];
|
||||
if (this.cacheFileRegExps.has(regExpKey)) {
|
||||
const cached = this.cacheFileRegExps.get(regExpKey)!;
|
||||
ignoreFilter = cached[1];
|
||||
targetFilter = cached[0];
|
||||
} else {
|
||||
ignoreFilter = this.dependencies.getFileRegExp("syncInternalFilesIgnorePatterns");
|
||||
targetFilter = this.dependencies.getFileRegExp("syncInternalFilesTargetPatterns");
|
||||
this.cacheFileRegExps.clear();
|
||||
this.cacheFileRegExps.set(regExpKey, [targetFilter, ignoreFilter]);
|
||||
}
|
||||
return { ignoreFilter, targetFilter };
|
||||
}
|
||||
|
||||
private async isTargetFileEligible(path: FilePath): Promise<boolean> {
|
||||
const result = matchesHiddenFileSyncPatterns(path, this.parseRegExpSettings()) && isHiddenFileSyncPath(path);
|
||||
// console.warn(`Assertion: isTargetFile(${path}) : ${result ? "✔️" : "❌"}`);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
const resultByFile = await this.dependencies.isIgnoredByIgnoreFile(path);
|
||||
// console.warn(`${path} -> isIgnoredByIgnoreFile: ${resultByFile ? "❌" : "✔️"}`);
|
||||
return !resultByFile;
|
||||
}
|
||||
|
||||
private async isTargetFile(path: FilePath): Promise<boolean> {
|
||||
if (this.dependencies?.ownsLocalFile(path) === false) return false;
|
||||
return await this.isTargetFileEligible(path);
|
||||
}
|
||||
|
||||
private async trackScannedDatabaseChange(
|
||||
processFiles: MetaEntry[],
|
||||
showNotice: boolean = false,
|
||||
@@ -670,7 +648,7 @@ Offline Changed files: ${processFiles.length}`;
|
||||
const processes = processFiles.map(async (file) => {
|
||||
try {
|
||||
const path = stripAllPrefixes(this.getPath(file));
|
||||
if (!(await this.isTargetFile(path))) {
|
||||
if (!(await this.pathAdmission.isTargetFile(path))) {
|
||||
this._log(
|
||||
`Database file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -834,30 +812,6 @@ Offline Changed files: ${files.length}`;
|
||||
|
||||
// <-- Database Event Functions
|
||||
|
||||
// --> Notification for Config Change
|
||||
private readonly queuedNotificationFiles = new Set<string>();
|
||||
private notifyConfigChange() {
|
||||
const updatedFolders = [...this.queuedNotificationFiles];
|
||||
this.queuedNotificationFiles.clear();
|
||||
if (this.disposed) return;
|
||||
this.dependencies.showConfigurationChangeNotice(updatedFolders);
|
||||
}
|
||||
|
||||
private queueNotification(key: FilePath) {
|
||||
if (this.disposed) return;
|
||||
if (this.settings.suppressNotifyHiddenFilesChange) {
|
||||
return;
|
||||
}
|
||||
const configDir = this.dependencies.getConfigDir();
|
||||
if (!key.startsWith(configDir)) return;
|
||||
const dirName = key.split("/").slice(0, -1).join("/");
|
||||
this.queuedNotificationFiles.add(dirName);
|
||||
scheduleTask(HIDDEN_FILE_NOTIFICATION_TASK, 1000, () => {
|
||||
this.notifyConfigChange();
|
||||
});
|
||||
}
|
||||
// <-- Notification for Config Change
|
||||
|
||||
// --> Initialization functions
|
||||
|
||||
private async rebuildMerging(showNotice: boolean, targetFiles: FilePath[] | false = false) {
|
||||
@@ -948,7 +902,7 @@ Offline Changed files: ${files.length}`;
|
||||
.map((e) => e.doc) as MetaEntry[];
|
||||
const files = [] as MetaEntry[];
|
||||
for (const file of allFiles) {
|
||||
if (await this.isTargetFile(stripAllPrefixes(this.getPath(file)))) {
|
||||
if (await this.pathAdmission.isTargetFile(stripAllPrefixes(this.getPath(file)))) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
@@ -1109,7 +1063,7 @@ Offline Changed files: ${files.length}`;
|
||||
const findRoot = this.dependencies.getRootPath();
|
||||
|
||||
const filenames = await collectOptionalFileSyncFiles(this.dependencies, findRoot, {
|
||||
shouldInclude: (path) => this.isTargetFile(path as FilePath),
|
||||
shouldInclude: (path) => this.pathAdmission.isTargetFile(path as FilePath),
|
||||
onError: (path, error) => {
|
||||
this._log(`Could not traverse(HiddenSync):${path}`, LOG_LEVEL_INFO);
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
|
||||
@@ -14,24 +14,22 @@ function callPrivate<T extends (...args: never[]) => unknown>(context: HiddenFil
|
||||
return operation.bind(context) as T;
|
||||
}
|
||||
|
||||
describe("HiddenFileSyncContext configuration-change notices", () => {
|
||||
it("releases processors, transient queues, the pattern cache, activity, and the host Notice effect", () => {
|
||||
describe("HiddenFileSyncContext lifecycle", () => {
|
||||
it("releases its processors, capability owners, and host conflict dialogues", () => {
|
||||
const periodicInternalFileScanProcessor = { disable: vi.fn() };
|
||||
const conflictResolution = { dispose: vi.fn() };
|
||||
const queuedNotificationFiles = new Set([".obsidian/plugins/example"]);
|
||||
const cacheFileRegExps = new Map([["patterns", []]]);
|
||||
const publishActivity = vi.fn();
|
||||
const changeProcessor = { dispose: vi.fn() };
|
||||
const hideConfigurationChangeNotice = vi.fn();
|
||||
const pathAdmission = { dispose: vi.fn() };
|
||||
const changeNotifier = { dispose: vi.fn() };
|
||||
const closeJsonConflictDialogs = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: { publishActivity, hideConfigurationChangeNotice, closeJsonConflictDialogs },
|
||||
dependencies: { closeJsonConflictDialogs },
|
||||
periodicInternalFileScanProcessor,
|
||||
conflictResolution,
|
||||
changeProcessor,
|
||||
queuedNotificationFiles,
|
||||
cacheFileRegExps,
|
||||
pathAdmission,
|
||||
changeNotifier,
|
||||
eventCount: 4,
|
||||
processingCount: 2,
|
||||
});
|
||||
@@ -41,11 +39,10 @@ describe("HiddenFileSyncContext configuration-change notices", () => {
|
||||
|
||||
expect(periodicInternalFileScanProcessor.disable).toHaveBeenCalledOnce();
|
||||
expect(conflictResolution.dispose).toHaveBeenCalledOnce();
|
||||
expect(queuedNotificationFiles.size).toBe(0);
|
||||
expect(cacheFileRegExps.size).toBe(0);
|
||||
expect(changeProcessor.dispose).toHaveBeenCalledOnce();
|
||||
expect(pathAdmission.dispose).toHaveBeenCalledOnce();
|
||||
expect(changeNotifier.dispose).toHaveBeenCalledOnce();
|
||||
expect(closeJsonConflictDialogs).toHaveBeenCalledOnce();
|
||||
expect(hideConfigurationChangeNotice).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not report Hidden File Sync as ready before the main runtime is ready", () => {
|
||||
@@ -63,26 +60,6 @@ describe("HiddenFileSyncContext configuration-change notices", () => {
|
||||
expect(callPrivate<() => boolean>(hiddenFileSync, "isReady")()).toBe(false);
|
||||
});
|
||||
|
||||
it("settles one batch of changed folders through the host notification effect", () => {
|
||||
const showConfigurationChangeNotice = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: { showConfigurationChangeNotice },
|
||||
queuedNotificationFiles: new Set([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]),
|
||||
});
|
||||
|
||||
callPrivate<() => void>(hiddenFileSync, "notifyConfigChange")();
|
||||
|
||||
expect(showConfigurationChangeNotice).toHaveBeenCalledWith([
|
||||
".obsidian/plugins/alpha",
|
||||
".obsidian/plugins/beta",
|
||||
".obsidian",
|
||||
]);
|
||||
expect((hiddenFileSync as unknown as { queuedNotificationFiles: Set<string> }).queuedNotificationFiles.size).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => {
|
||||
const progress = {
|
||||
log: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CustomRegExpSourceList, FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { CustomRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
|
||||
import {
|
||||
isHiddenFileSyncPath,
|
||||
matchesHiddenFileSyncPatterns,
|
||||
type HiddenFileSyncPathFilters,
|
||||
} from "./hiddenFileSyncPathPolicy.ts";
|
||||
|
||||
type HiddenFileSyncPathRegExpKey = "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns";
|
||||
|
||||
export type HiddenFileSyncPathAdmissionDependencies = {
|
||||
getTargetPatternSource(): CustomRegExpSourceList<",">;
|
||||
getIgnorePatternSource(): CustomRegExpSourceList<",">;
|
||||
getFileRegExp(key: HiddenFileSyncPathRegExpKey): readonly CustomRegExp[];
|
||||
isIgnoredByIgnoreFile(path: FilePath): Promise<boolean>;
|
||||
ownsLocalFile(path: FilePath): boolean;
|
||||
};
|
||||
|
||||
export type HiddenFileSyncPathAdmission = {
|
||||
isTargetFileEligible(path: FilePath): Promise<boolean>;
|
||||
isTargetFile(path: FilePath): Promise<boolean>;
|
||||
invalidatePatternCache(): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
class HiddenFileSyncPathAdmissionOwner implements HiddenFileSyncPathAdmission {
|
||||
private readonly cacheFileRegExps = new Map<string, HiddenFileSyncPathFilters>();
|
||||
|
||||
constructor(private readonly dependencies: HiddenFileSyncPathAdmissionDependencies) {}
|
||||
|
||||
private parseRegExpSettings(): HiddenFileSyncPathFilters {
|
||||
const targetPatternSource = this.dependencies.getTargetPatternSource();
|
||||
const ignorePatternSource = this.dependencies.getIgnorePatternSource();
|
||||
const regExpKey = `${targetPatternSource}||${ignorePatternSource}`;
|
||||
const cached = this.cacheFileRegExps.get(regExpKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Keep the inherited parser order: ignore patterns are read before target patterns.
|
||||
const ignoreFilter = this.dependencies.getFileRegExp("syncInternalFilesIgnorePatterns");
|
||||
const targetFilter = this.dependencies.getFileRegExp("syncInternalFilesTargetPatterns");
|
||||
const filters: HiddenFileSyncPathFilters = { ignoreFilter, targetFilter };
|
||||
this.cacheFileRegExps.clear();
|
||||
this.cacheFileRegExps.set(regExpKey, filters);
|
||||
return filters;
|
||||
}
|
||||
|
||||
async isTargetFileEligible(path: FilePath): Promise<boolean> {
|
||||
const result = matchesHiddenFileSyncPatterns(path, this.parseRegExpSettings()) && isHiddenFileSyncPath(path);
|
||||
if (!result) return false;
|
||||
return !(await this.dependencies.isIgnoredByIgnoreFile(path));
|
||||
}
|
||||
|
||||
async isTargetFile(path: FilePath): Promise<boolean> {
|
||||
// Ownership is checked first so another optional-file owner cannot be filtered by this feature.
|
||||
if (this.dependencies.ownsLocalFile(path) === false) return false;
|
||||
return await this.isTargetFileEligible(path);
|
||||
}
|
||||
|
||||
invalidatePatternCache(): void {
|
||||
this.cacheFileRegExps.clear();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.cacheFileRegExps.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function createHiddenFileSyncPathAdmission(
|
||||
dependencies: HiddenFileSyncPathAdmissionDependencies
|
||||
): HiddenFileSyncPathAdmission {
|
||||
return new HiddenFileSyncPathAdmissionOwner(dependencies);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CustomRegExpSourceList, FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { CustomRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
|
||||
import {
|
||||
createHiddenFileSyncPathAdmission,
|
||||
type HiddenFileSyncPathAdmissionDependencies,
|
||||
} from "./hiddenFileSyncPathAdmission.ts";
|
||||
|
||||
const PATH = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
|
||||
const pattern = (matches: (path: string) => boolean) => ({ test: vi.fn(matches) }) as unknown as CustomRegExp;
|
||||
|
||||
function createDependencies(
|
||||
overrides: Partial<HiddenFileSyncPathAdmissionDependencies> = {}
|
||||
): HiddenFileSyncPathAdmissionDependencies & {
|
||||
getFileRegExp: ReturnType<typeof vi.fn>;
|
||||
isIgnoredByIgnoreFile: ReturnType<typeof vi.fn>;
|
||||
ownsLocalFile: ReturnType<typeof vi.fn>;
|
||||
getTargetPatternSource: ReturnType<typeof vi.fn>;
|
||||
getIgnorePatternSource: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const getTargetPatternSource = vi.fn(() => "target" as CustomRegExpSourceList<",">);
|
||||
const getIgnorePatternSource = vi.fn(() => "ignore" as CustomRegExpSourceList<",">);
|
||||
const getFileRegExp = vi.fn((key: "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns") => {
|
||||
if (key == "syncInternalFilesIgnorePatterns") return [];
|
||||
return [];
|
||||
});
|
||||
const isIgnoredByIgnoreFile = vi.fn(async () => false);
|
||||
const ownsLocalFile = vi.fn(() => true);
|
||||
return {
|
||||
getTargetPatternSource,
|
||||
getIgnorePatternSource,
|
||||
getFileRegExp,
|
||||
isIgnoredByIgnoreFile,
|
||||
ownsLocalFile,
|
||||
...overrides,
|
||||
} as HiddenFileSyncPathAdmissionDependencies & {
|
||||
getFileRegExp: ReturnType<typeof vi.fn>;
|
||||
isIgnoredByIgnoreFile: ReturnType<typeof vi.fn>;
|
||||
ownsLocalFile: ReturnType<typeof vi.fn>;
|
||||
getTargetPatternSource: ReturnType<typeof vi.fn>;
|
||||
getIgnorePatternSource: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("Hidden File Sync path admission", () => {
|
||||
it("checks composition ownership before reading pattern settings", async () => {
|
||||
const dependencies = createDependencies({ ownsLocalFile: vi.fn(() => false) });
|
||||
const admission = createHiddenFileSyncPathAdmission(dependencies);
|
||||
|
||||
await expect(admission.isTargetFile(PATH)).resolves.toBe(false);
|
||||
|
||||
expect(dependencies.ownsLocalFile).toHaveBeenCalledWith(PATH);
|
||||
expect(dependencies.getTargetPatternSource).not.toHaveBeenCalled();
|
||||
expect(dependencies.getIgnorePatternSource).not.toHaveBeenCalled();
|
||||
expect(dependencies.getFileRegExp).not.toHaveBeenCalled();
|
||||
expect(dependencies.isIgnoredByIgnoreFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks static path and pattern policy before the asynchronous ignore-file policy", async () => {
|
||||
const isIgnoredByIgnoreFile = vi.fn(async () => false);
|
||||
const targetFilter = [pattern(() => false)];
|
||||
const dependencies = createDependencies({
|
||||
getFileRegExp: vi.fn((key: "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns") =>
|
||||
key == "syncInternalFilesTargetPatterns" ? targetFilter : []
|
||||
),
|
||||
isIgnoredByIgnoreFile,
|
||||
});
|
||||
const admission = createHiddenFileSyncPathAdmission(dependencies);
|
||||
|
||||
await expect(admission.isTargetFile(PATH)).resolves.toBe(false);
|
||||
await expect(admission.isTargetFile("notes/example.md" as FilePath)).resolves.toBe(false);
|
||||
|
||||
expect(isIgnoredByIgnoreFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adopts target and ignore patterns while preserving their policy order", async () => {
|
||||
const calls: string[] = [];
|
||||
const targetFilter = [pattern(() => true)];
|
||||
const ignoreFilter = [pattern(() => true)];
|
||||
const getFileRegExp = vi.fn((key: "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns") => {
|
||||
calls.push(key);
|
||||
return key == "syncInternalFilesTargetPatterns" ? targetFilter : ignoreFilter;
|
||||
});
|
||||
const isIgnoredByIgnoreFile = vi.fn(async () => false);
|
||||
const dependencies = createDependencies({ getFileRegExp, isIgnoredByIgnoreFile });
|
||||
const admission = createHiddenFileSyncPathAdmission(dependencies);
|
||||
|
||||
await expect(admission.isTargetFile(PATH)).resolves.toBe(false);
|
||||
|
||||
expect(calls).toEqual(["syncInternalFilesIgnorePatterns", "syncInternalFilesTargetPatterns"]);
|
||||
expect(targetFilter[0].test).not.toHaveBeenCalled();
|
||||
expect(isIgnoredByIgnoreFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches parsed filters per owner and refreshes them when sources or settings change", async () => {
|
||||
const targetFilter = [pattern(() => true)];
|
||||
const ignoreFilter: CustomRegExp[] = [];
|
||||
let targetSource = "target" as CustomRegExpSourceList<",">;
|
||||
const getTargetPatternSource = vi.fn(() => targetSource);
|
||||
const getFileRegExp = vi.fn((key: "syncInternalFilesIgnorePatterns" | "syncInternalFilesTargetPatterns") =>
|
||||
key == "syncInternalFilesTargetPatterns" ? targetFilter : ignoreFilter
|
||||
);
|
||||
const dependencies = createDependencies({ getTargetPatternSource, getFileRegExp });
|
||||
const admission = createHiddenFileSyncPathAdmission(dependencies);
|
||||
|
||||
await expect(admission.isTargetFile(PATH)).resolves.toBe(true);
|
||||
await expect(admission.isTargetFile(PATH)).resolves.toBe(true);
|
||||
expect(getFileRegExp).toHaveBeenCalledTimes(2);
|
||||
|
||||
targetSource = "changed" as CustomRegExpSourceList<",">;
|
||||
await expect(admission.isTargetFile(PATH)).resolves.toBe(true);
|
||||
expect(getFileRegExp).toHaveBeenCalledTimes(4);
|
||||
|
||||
admission.invalidatePatternCache();
|
||||
await expect(admission.isTargetFile(PATH)).resolves.toBe(true);
|
||||
expect(getFileRegExp).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("does not share the pattern cache between owners and clears it on disposal", async () => {
|
||||
const firstDependencies = createDependencies();
|
||||
const secondDependencies = createDependencies();
|
||||
const first = createHiddenFileSyncPathAdmission(firstDependencies);
|
||||
const second = createHiddenFileSyncPathAdmission(secondDependencies);
|
||||
|
||||
await first.isTargetFile(PATH);
|
||||
await first.isTargetFile(PATH);
|
||||
await second.isTargetFile(PATH);
|
||||
expect(firstDependencies.getFileRegExp).toHaveBeenCalledTimes(2);
|
||||
expect(secondDependencies.getFileRegExp).toHaveBeenCalledTimes(2);
|
||||
|
||||
first.dispose();
|
||||
first.dispose();
|
||||
await first.isTargetFile(PATH);
|
||||
expect(firstDependencies.getFileRegExp).toHaveBeenCalledTimes(4);
|
||||
expect(secondDependencies.getFileRegExp).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user