Merge main and verify comparison status with published Commonlib

This commit is contained in:
vorotamoroz committed 2026-10-26 04:09:02 +00:00
commit c9381d6a59
38 files changed
+4615 -119

No files matched your search

@@ -7,6 +7,9 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
@@ -0,0 +1,298 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: (path: string) => path,
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
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/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: { onEvent: vi.fn() },
}));
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
import { ConfigSync, PluginDataExDisplayV2, pluginManifests } from "./CmdConfigSync";
const filenames = ["main.js", "manifest.json", "styles.css"];
const content: Record<string, string> = {
"main.js": "module.exports = class ExamplePlugin {};",
"manifest.json": JSON.stringify({ id: "example", name: "Example", version: "1.0.0" }),
"styles.css": ".example { color: red; }",
};
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
async function catalogue() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.syncOnlyRegEx = "\\.(?:md|png|pdf)$" as typeof fixture.settings.syncOnlyRegEx;
const paths = filenames.map((name) => `ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix);
for (const [index, path] of paths.entries()) {
const wireContent = `fixture-header\u200b\n\u200c${btoa(content[filenames[index]])}`;
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, wireContent))).resolves.toMatchObject({
ok: true,
});
}
const gates = new Map(paths.map((path) => [path, deferred()]));
const readEntry = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
await gates.get(path)?.promise;
return readEntry(path, ...args);
});
const written = new Map<string, string>();
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: {
localDatabase: fixture.localDatabase,
settings: fixture.settings,
services: fixture.services,
storageAccess: {
ensureDir: vi.fn(async () => true),
readHiddenFileText: vi.fn(async (path: string) => written.get(path) ?? ""),
writeHiddenFileAuto: vi.fn(async (path: string, data: string) => {
written.set(path, data);
}),
},
},
pluginList: [],
pluginListGeneration: 0,
loadedManifest_mTime: new Map(),
updatingV2Count: 0,
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
_log: vi.fn(),
storeCustomisationFileV2: vi.fn(async () => true),
});
const updates: Promise<void>[] = [];
const update = configSync.updatePluginListV2.bind(configSync);
vi.spyOn(configSync, "updatePluginListV2").mockImplementation((...args) => {
const promise = update(...args);
updates.push(promise);
return promise;
});
const release = (order = filenames) => {
for (const name of order) gates.get(`ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix)!.resolve();
};
return { ...fixture, configSync, updates, paths, release, written };
}
afterEach(async () => {
vi.restoreAllMocks();
pluginManifests.clear();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("issue #1271: rebuilding a multi-file Customisation Sync item", () => {
it.each([
{ order: filenames },
{ order: [...filenames].reverse() },
{ order: ["manifest.json", "main.js", "styles.css"] },
])("retains all per-file documents when reads are released as $order", async ({ order }) => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release(order);
await Promise.all(fixture.updates);
const documents = await fixture.database.allDocs({ keys: fixture.paths });
expect(documents.rows).toHaveLength(3);
expect(fixture.configSync.pluginList).toHaveLength(1);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("applies all three files after the first reload", async () => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release();
await Promise.all(fixture.updates);
await expect(
fixture.configSync.applyDataV2(fixture.configSync.pluginList[0] as PluginDataExDisplayV2)
).resolves.toBe(true);
expect([...fixture.written.keys()].sort()).toEqual(
filenames.map((name) => `.obsidian/plugins/example/${name}`).sort()
);
for (const name of filenames)
expect(fixture.written.get(`.obsidian/plugins/example/${name}`)).toBe(content[name]);
});
it("fills an existing row during a second enumeration", async () => {
const fixture = await catalogue();
await fixture.configSync.reloadPluginList(false);
fixture.release();
await Promise.all(fixture.updates);
await fixture.configSync.updatePluginList(false);
await Promise.all(fixture.updates);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("retains all files when item updates run sequentially", async () => {
const fixture = await catalogue();
fixture.release();
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
filenames.map((name) => `plugins/example/${name}`).sort()
);
});
it("updates a different item while another item's reads are pending", async () => {
const fixture = await catalogue();
const otherPath = "ix:device/PLUGIN_MAIN/other%main.js" as FilePathWithPrefix;
await fixture.localDatabase.putDBEntry(
optionalFileEntry(otherPath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
);
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
try {
await fixture.configSync.updatePluginListV2(false, otherPath);
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["other"]);
expect(fixture.configSync.updatingV2Count).toBe(3);
} finally {
fixture.release();
await Promise.all(pending);
}
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it("continues updating an item after an earlier read fails", async () => {
const fixture = await catalogue();
fixture.release();
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockRejectedValueOnce(new Error("Read failed"));
const failed = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
const next = fixture.configSync.updatePluginListV2(false, fixture.paths[1]);
await expect(failed).rejects.toThrow("Read failed");
await next;
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
"plugins/example/manifest.json",
]);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it.each(["read", "file update"])("reload discards cached files while an earlier %s is pending", async (phase) => {
const fixture = await catalogue();
fixture.release();
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
const earlierRead = deferred();
const freshRead = deferred();
const entered = deferred();
const createFile = fixture.configSync.createPluginDataExFileV2.bind(fixture.configSync);
let reads = 0;
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockImplementation(async (path) => {
if (reads++ === 0) {
if (phase === "read") {
entered.resolve();
await earlierRead.promise;
}
} else {
await freshRead.promise;
}
return createFile(path);
});
if (phase === "file update") {
const entry = fixture.configSync.pluginList[0] as PluginDataExDisplayV2;
const setFile = entry.setFile.bind(entry);
vi.spyOn(entry, "setFile").mockImplementationOnce(async (file) => {
entered.resolve();
await earlierRead.promise;
await setFile(file);
});
}
const pending = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
try {
await entered.promise;
for (const path of fixture.paths.slice(1)) await fixture.database.remove(await fixture.database.get(path));
await fixture.configSync.reloadPluginList(false);
earlierRead.resolve();
await pending;
} finally {
earlierRead.resolve();
freshRead.resolve();
await Promise.all(fixture.updates);
}
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
"plugins/example/main.js",
]);
});
it("clearing the disabled feature prevents pending updates from repopulating the list", async () => {
const fixture = await catalogue();
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
fixture.settings.usePluginSync = false;
await fixture.configSync.updatePluginList(false);
fixture.release();
await Promise.all(pending);
expect(fixture.configSync.pluginList).toEqual([]);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
it("a superseded enumeration cannot reintroduce a deleted item", async () => {
const fixture = await catalogue();
const obsoletePath = "ix:device/PLUGIN_MAIN/obsolete%main.js" as FilePathWithPrefix;
await fixture.localDatabase.putDBEntry(
optionalFileEntry(obsoletePath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
);
const enumerate = fixture.localDatabase.findEntries.bind(fixture.localDatabase);
type EnumeratedEntry = ReturnType<typeof enumerate> extends AsyncGenerator<infer Entry> ? Entry : never;
const obsoleteDocs: EnumeratedEntry[] = [];
for await (const doc of enumerate(obsoletePath, `${obsoletePath}\u{10ffff}`, { include_docs: true })) {
obsoleteDocs.push(doc);
}
const entered = deferred();
const release = deferred();
let scans = 0;
vi.spyOn(fixture.localDatabase, "findEntries").mockImplementation((...args) => {
if (scans++ !== 0) return enumerate(...args);
return (async function* () {
entered.resolve();
await release.promise;
yield* obsoleteDocs;
})();
});
const pending = fixture.configSync.updatePluginList(false);
try {
await entered.promise;
await fixture.database.remove(await fixture.database.get(obsoletePath));
await fixture.configSync.reloadPluginList(false);
} finally {
release.resolve();
fixture.release();
await pending;
await Promise.all(fixture.updates);
}
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["example"]);
expect(fixture.configSync.pluginList[0].files).toHaveLength(3);
expect(fixture.configSync.updatingV2Count).toBe(0);
});
});
@@ -0,0 +1,220 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
vi.mock("@/modules/features/ModuleLog.ts", () => ({
MARK_DONE: "",
}));
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/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { ConfigSync } from "./CmdConfigSync";
import { createOptionalFileSyncDatabase } from "../../../test/fixtures/optionalFileSyncDatabase";
const path = ".obsidian/snippets/test.css" as FilePath;
const fixtures: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function createConfigSync(useV2 = false) {
const fixture = await createOptionalFileSyncDatabase();
fixtures.push(fixture);
fixture.settings.usePluginSyncV2 = useV2;
const files = new Map<string, { content: string; mtime: number }>([
[path, { content: "body { color: red; }", mtime: 2000 }],
]);
const storageAccess = {
statHidden: vi.fn(async (file: string) => {
const entry = files.get(file);
return entry
? { type: "file" as const, ctime: 1000, mtime: entry.mtime, size: entry.content.length }
: null;
}),
readHiddenFileBinary: vi.fn(async (file: string) => {
const entry = files.get(file);
if (!entry) throw new Error(`Missing fixture: ${file}`);
return new TextEncoder().encode(entry.content).buffer;
}),
};
const services = {
...fixture.services,
appLifecycle: { isReady: () => false, isSuspended: () => true },
database: { ...fixture.services.database, isDatabaseReady: vi.fn(() => true) },
path: {
...fixture.services.path,
isMarkedAsSameChanges: vi.fn(),
markChangesAreSame: vi.fn(),
},
};
const config = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(config, {
core: { settings: fixture.settings, localDatabase: fixture.localDatabase, storageAccess, services },
_log: vi.fn(),
updatePluginList: vi.fn(async () => undefined),
updatePluginListV2: vi.fn(async () => undefined),
recentProcessedInternalFiles: [],
});
const databasePath = useV2 ? config.filenameWithUnifiedKey(path) : config.filenameToUnifiedKey(path);
return { fixture, config, files, services, storageAccess, databasePath };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtures.splice(0)) await fixture.dispose();
});
describe("Customisation Sync optional save completion", () => {
const flush = { whenFlushRequested: Promise.resolve() };
it("waits for the database write while the application is suspended or still starting", async () => {
const { fixture, config } = await createConfigSync();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
const spy = vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
await gate;
return await put(...args);
});
let completed = false;
const result = config._processOptionalFileEventWithResult(path, flush).then((outcome) => {
completed = true;
return outcome;
});
await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce());
expect(completed).toBe(false);
release();
await expect(result).resolves.toEqual({ kind: "completed" });
});
it.each([false, true])("confirms identical contents and stores a same-time edit (V2: %s)", async (useV2) => {
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
const first = await fixture.database.get(databasePath);
await config._processOptionalFileEventWithResult(path, flush);
expect((await fixture.database.get(databasePath))._rev).toBe(first._rev);
files.get(path)!.content = "body { color: tan; }";
await config._processOptionalFileEventWithResult(path, flush);
expect((await fixture.database.get(databasePath))._rev).not.toBe(first._rev);
});
it.each([
{ useV2: false, throws: false },
{ useV2: true, throws: false },
{ useV2: false, throws: true },
{ useV2: true, throws: true },
])("rejects a failed write and permits the unchanged notification to retry: %j", async ({ useV2, throws }) => {
const { fixture, config, databasePath } = await createConfigSync(useV2);
const failure = new Error("temporary write failure");
const put = vi.spyOn(fixture.localDatabase, "putDBEntry");
if (throws) put.mockRejectedValueOnce(failure);
else put.mockResolvedValueOnce(false);
const failed = config._processOptionalFileEventWithResult(path, flush);
if (throws) await expect(failed).rejects.toBe(failure);
else await expect(failed).rejects.toThrow("Could not save customisation");
await expect(fixture.localDatabase.getDBEntry(databasePath)).resolves.toBe(false);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
expect(put).toHaveBeenCalledTimes(2);
expect(await fixture.localDatabase.getDBEntry(databasePath)).not.toBe(false);
});
it.each([false, true])("confirms deletion and treats a repeated deletion as complete (V2: %s)", async (useV2) => {
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
await config._processOptionalFileEventWithResult(path, flush);
files.delete(path);
const put = vi.spyOn(fixture.localDatabase, "putRaw");
put.mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
await expect(config._processOptionalFileEventWithResult(path, flush)).rejects.toThrow(
"Could not delete customisation"
);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
const deleted = await fixture.database.get(databasePath);
expect("deleted" in deleted && deleted.deleted).toBe(true);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
expect((await fixture.database.get(databasePath))._rev).toBe(deleted._rev);
});
it("distinguishes a different owner and unmet prerequisites", async () => {
const { config, services, storageAccess } = await createConfigSync();
await expect(config._processOptionalFileEventWithResult("notes.md" as FilePath, flush)).resolves.toEqual({
kind: "not-applicable",
});
services.database.isDatabaseReady.mockReturnValue(false);
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
kind: "veto",
value: "database-not-ready",
});
services.database.isDatabaseReady.mockReturnValue(true);
vi.spyOn(services.setting, "getDeviceAndVaultName").mockReturnValue("");
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
kind: "veto",
value: "device-name-not-configured",
});
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
});
it("preserves every V2 file when neighbouring plug-in files arrive together", async () => {
const { fixture, config, files } = await createConfigSync(true);
const main = ".obsidian/plugins/example/main.js" as FilePath;
const style = ".obsidian/plugins/example/styles.css" as FilePath;
files.set(main, { content: "module.exports = {};", mtime: 3000 });
files.set(style, { content: "body {}", mtime: 3000 });
await Promise.all([
config._processOptionalFileEventWithResult(main, flush),
config._processOptionalFileEventWithResult(style, flush),
]);
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(main))).not.toBe(false);
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(style))).not.toBe(false);
});
it("does not acknowledge a database deletion which was not accepted", async () => {
const { fixture, config, databasePath } = await createConfigSync();
await config.storeCustomizationFiles(path);
const original = await fixture.localDatabase.getDBEntry(databasePath);
expect(original).not.toBe(false);
vi.spyOn(fixture.localDatabase, "putRaw").mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
await expect(config.deleteConfigOnDatabase(databasePath)).resolves.toBe(false);
const unchanged = await fixture.localDatabase.getDBEntry(databasePath);
expect(unchanged && getDocData(unchanged.data)).toBe(original && getDocData(original.data));
});
});
+124 -34
View File
@@ -69,6 +69,10 @@ import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { PendingConfigSaves } from "./PendingConfigSaves";
const d = "\u200b";
const d2 = "\n";
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
@@ -412,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
pluginList: IPluginDataExDisplay[] = [];
private pluginListGeneration = 0;
showPluginSyncModal() {
if (!this.isThisModuleEnabled()) {
return;
@@ -532,6 +537,7 @@ export class ConfigSync extends LiveSyncCommands {
return Promise.resolve(true);
}
async reloadPluginList(showMessage: boolean) {
this.pluginListGeneration++;
this.pluginList = [];
this.loadedManifest_mTime.clear();
pluginList.set(this.pluginList);
@@ -761,39 +767,46 @@ export class ConfigSync extends LiveSyncCommands {
updatingV2Count = 0;
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
const generation = this.pluginListGeneration;
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
try {
this.updatingV2Count++;
pluginV2Progress.set(this.updatingV2Count);
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
let entry: PluginDataExDisplayV2 | undefined = undefined;
await serialized(`config:catalogue:${pathV1}`, async () => {
if (!isCurrent()) return;
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
let entry: PluginDataExDisplayV2 | undefined = undefined;
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
if (newEntry) {
entry = newEntry;
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
if (newEntry) {
entry = newEntry;
}
} else if (oldEntry instanceof PluginDataExDisplayV2) {
entry = oldEntry;
}
} else if (oldEntry instanceof PluginDataExDisplayV2) {
entry = oldEntry;
}
if (!entry) return;
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
if (file) {
await entry.setFile(file);
} else {
entry.deleteFile(unifiedFilenameWithKey);
if (entry.files.length == 0) {
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
if (!entry) return;
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
if (!isCurrent()) return;
if (file) {
await entry.setFile(file);
} else {
entry.deleteFile(unifiedFilenameWithKey);
if (entry.files.length == 0) {
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
}
}
}
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
newList.push(entry);
this.pluginList = newList;
if (!isCurrent()) return;
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
newList.push(entry);
this.pluginList = newList;
scheduleTask("updatePluginListV2", 100, () => {
pluginList.set(this.pluginList);
scheduleTask("updatePluginListV2", 100, () => {
pluginList.set(this.pluginList);
});
});
} finally {
this.updatingV2Count--;
@@ -871,11 +884,13 @@ export class ConfigSync extends LiveSyncCommands {
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
if (!this.isThisModuleEnabled()) {
this.pluginListGeneration++;
this.pluginScanProcessor.clearQueue();
this.pluginList = [];
pluginList.set(this.pluginList);
return;
}
const generation = this.pluginListGeneration;
try {
this.updatingV2Count++;
pluginV2Progress.set(this.updatingV2Count);
@@ -888,6 +903,7 @@ export class ConfigSync extends LiveSyncCommands {
})
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
for await (const v of plugins) {
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
if (v.deleted || v._deleted) continue;
if (v.path.indexOf("%") !== -1) {
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
@@ -1206,7 +1222,7 @@ export class ConfigSync extends LiveSyncCommands {
}
recentProcessedInternalFiles = [] as string[];
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> {
async makeEntryFromFile(path: FilePath, requireCompletion = false): Promise<false | PluginDataExFile> {
const stat = await this.core.storageAccess.statHidden(path);
let version: string | undefined;
let displayName: string | undefined;
@@ -1240,6 +1256,7 @@ export class ConfigSync extends LiveSyncCommands {
} catch (ex) {
this._log(`The file ${path} could not be encoded`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
const mtime = stat.mtime;
@@ -1254,6 +1271,15 @@ export class ConfigSync extends LiveSyncCommands {
}
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
return await this.storeCustomisationFileV2Core(path, term, force, false);
}
private async storeCustomisationFileV2Core(
path: FilePath,
term: string,
force: boolean,
requireCompletion: boolean
) {
const vf = this.filenameWithUnifiedKey(path, term);
return await serialized(`plugin-${vf}`, async () => {
const prefixedFileName = vf;
@@ -1261,7 +1287,8 @@ export class ConfigSync extends LiveSyncCommands {
const id = await this.path2id(prefixedFileName);
const stat = await this.core.storageAccess.statHidden(path);
if (!stat) {
return false;
if (!requireCompletion) return false;
return await this.deleteConfigOnDatabaseCore(vf, force, true);
}
const mtime = stat.mtime;
const content = await this.core.storageAccess.readHiddenFileBinary(path);
@@ -1285,7 +1312,10 @@ export class ConfigSync extends LiveSyncCommands {
eden: {},
};
} else {
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
if (
!requireCompletion &&
this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
LOG_LEVEL_DEBUG
@@ -1300,7 +1330,7 @@ export class ConfigSync extends LiveSyncCommands {
const dataStart = dataSrc.indexOf(DUMMY_END);
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
const oldContentArray = base64ToArrayBuffer(oldContent);
if (await isDocContentSame(oldContentArray, content)) {
if (!old.deleted && (await isDocContentSame(oldContentArray, content))) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
LOG_LEVEL_VERBOSE
@@ -1320,24 +1350,36 @@ export class ConfigSync extends LiveSyncCommands {
};
}
const ret = await this.localDatabase.putDBEntry(saveData);
if (requireCompletion && (!ret || !ret.ok))
throw new LiveSyncError(`Could not save customisation: ${path}`);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
return ret;
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
}
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
return await this.storeCustomizationFilesCore(path, termOverRide, false);
}
private async storeCustomizationFilesCore(
path: FilePath,
termOverRide: string | undefined,
requireCompletion: boolean
) {
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
if (term == "") {
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
if (requireCompletion) throw new LiveSyncError("The device name is not configured");
return;
}
if (this.useV2) {
return await this.storeCustomisationFileV2(path, term);
return await this.storeCustomisationFileV2Core(path, term, false, requireCompletion);
}
const vf = this.filenameToUnifiedKey(path, term);
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
@@ -1378,7 +1420,7 @@ export class ConfigSync extends LiveSyncCommands {
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
}
for (const target of fileTargets) {
const data = await this.makeEntryFromFile(target);
const data = await this.makeEntryFromFile(target, requireCompletion);
if (data == false) {
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
continue;
@@ -1398,9 +1440,9 @@ export class ConfigSync extends LiveSyncCommands {
// this._log(`Configuration saving: ${prefixedFileName}`);
if (dt.files.length == 0) {
this._log(`Nothing left: deleting.. ${path}`);
await this.deleteConfigOnDatabase(prefixedFileName);
const deleted = await this.deleteConfigOnDatabaseCore(prefixedFileName, false, requireCompletion);
await this.updatePluginList(false, prefixedFileName);
return;
return requireCompletion ? deleted : undefined;
}
const content = createTextBlob(serialize(dt));
@@ -1422,7 +1464,7 @@ export class ConfigSync extends LiveSyncCommands {
eden: {},
};
} else {
if (old.mtime == mtime) {
if (!requireCompletion && old.mtime == mtime) {
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
return true;
}
@@ -1443,7 +1485,7 @@ export class ConfigSync extends LiveSyncCommands {
}
});
const isSame = (await Promise.all(diffs)).every((e) => e == true);
if (isSame) {
if (isSame && !old.deleted) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
LOG_LEVEL_VERBOSE
@@ -1464,16 +1506,51 @@ export class ConfigSync extends LiveSyncCommands {
};
}
const ret = await this.localDatabase.putDBEntry(saveData);
if (requireCompletion && (!ret || !ret.ok))
throw new LiveSyncError(`Could not save customisation: ${path}`);
await this.updatePluginList(false, saveData.path);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
return ret;
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
}
private pendingOptionalSaves?: PendingConfigSaves;
async _processOptionalFileEventWithResult(
path: FilePath,
options?: OptionalFileEventOptions
): Promise<HandlerOutcome<void, string>> {
if (!this.isThisModuleEnabled() || !this.isTargetPath(path)) return { kind: "not-applicable" };
const configDir = normalizePath(this.configDir);
const handledElsewhere = Object.values(this.settings.pluginSyncExtendedSetting)
.filter((entry) => entry.mode !== MODE_SELECTIVE && entry.mode !== MODE_SHINY)
.flatMap((entry) => entry.files)
.some((file) => `${configDir}/${file}`.toLowerCase().startsWith(path.toLowerCase()));
if (handledElsewhere) return { kind: "not-applicable" };
if (!this._isDatabaseReady()) return { kind: "veto", value: "database-not-ready" };
const term = this.services.setting.getDeviceAndVaultName();
if (!term) return { kind: "veto", value: "device-name-not-configured" };
const stat = await this.core.storageAccess.statHidden(path);
if (stat && stat.type !== "file") return { kind: "not-applicable" };
// V1 saves a complete file group; V2 persists each file separately.
const key = this.useV2 ? this.filenameWithUnifiedKey(path, term) : this.filenameToUnifiedKey(path, term);
this.pendingOptionalSaves ??= new PendingConfigSaves();
await this.pendingOptionalSaves.enqueue(
key,
async () => {
await this.storeCustomizationFilesCore(path, term, true);
},
options?.whenFlushRequested
);
return { kind: "completed" };
}
async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> {
return await this.watchVaultRawEventsAsync(path);
}
@@ -1615,6 +1692,14 @@ export class ConfigSync extends LiveSyncCommands {
}
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
return await this.deleteConfigOnDatabaseCore(prefixedFileName, forceWrite, false);
}
private async deleteConfigOnDatabaseCore(
prefixedFileName: FilePathWithPrefix,
forceWrite: boolean,
requireCompletion: boolean
) {
// const id = await this.path2id(prefixedFileName);
const mtime = new Date().getTime();
return await serialized("file-x-" + prefixedFileName, async () => {
@@ -1640,13 +1725,15 @@ export class ConfigSync extends LiveSyncCommands {
type: "newnote",
};
}
await this.localDatabase.putRaw(saveData);
const result = await this.localDatabase.putRaw(saveData);
if (!result.ok) throw new LiveSyncError(`Could not delete customisation: ${prefixedFileName}`);
await this.updatePluginList(false, prefixedFileName);
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
return true;
} catch (ex) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (requireCompletion) throw ex;
return false;
}
});
@@ -1756,6 +1843,9 @@ export class ConfigSync extends LiveSyncCommands {
}
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
this._processOptionalFileEventWithResult.bind(this)
);
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
@@ -0,0 +1,49 @@
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
type PendingSave = {
save: () => Promise<void>;
completion: Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
timer?: CompatTimeoutHandle;
started: boolean;
};
/** Owns the quiet-period delay and completion of coalesced customisation saves. */
export class PendingConfigSaves {
private readonly pending = new Map<string, PendingSave>();
enqueue(key: string, save: () => Promise<void>, whenFlushRequested?: Promise<void>): Promise<void> {
let group = this.pending.get(key);
if (!group) {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const completion = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
group = { save, completion, resolve, reject, started: false };
this.pending.set(key, group);
} else {
group.save = save;
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
}
const captured = group;
group.timer = compatGlobal.setTimeout(() => this.run(key, captured), 100);
// Capture this group: a late flush of an earlier event must not release
// a newer group which happens to use the same customisation key.
void whenFlushRequested?.then(
() => this.run(key, captured),
() => {}
);
return group.completion;
}
private run(key: string, group: PendingSave): void {
if (group.started) return;
group.started = true;
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
this.pending.delete(key);
void Promise.resolve().then(group.save).then(group.resolve, group.reject);
}
}
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PendingConfigSaves } from "./PendingConfigSaves";
function deferred() {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
afterEach(() => vi.useRealTimers());
describe("pending customisation saves", () => {
it("retains the quiet period and completes every coalesced notification after the actual save", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const save = deferred();
const firstSave = vi.fn(async () => {});
const lastSave = vi.fn(() => save.promise);
const first = pending.enqueue("plugin", firstSave);
await vi.advanceTimersByTimeAsync(80);
const second = pending.enqueue("plugin", lastSave);
let completed = false;
const all = Promise.all([first, second]).then(() => {
completed = true;
});
await vi.advanceTimersByTimeAsync(99);
expect(lastSave).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(firstSave).not.toHaveBeenCalled();
expect(lastSave).toHaveBeenCalledOnce();
expect(completed).toBe(false);
save.resolve();
await all;
expect(completed).toBe(true);
});
it("flushes the captured group without waiting for or releasing later notifications", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const flush = deferred();
const lateFlush = deferred();
const firstSave = deferred();
const work = vi.fn(() => firstSave.promise);
const first = pending.enqueue("plugin", work, flush.promise);
const coalesced = pending.enqueue("plugin", work, lateFlush.promise);
flush.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(work).toHaveBeenCalledOnce();
const nextWork = vi.fn(async () => {});
const later = pending.enqueue("plugin", nextWork);
lateFlush.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(nextWork).not.toHaveBeenCalled();
firstSave.resolve();
await Promise.all([first, coalesced]);
expect(nextWork).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
await later;
expect(nextWork).toHaveBeenCalledOnce();
});
it("rejects every coalesced notification with the original failure and permits a later retry", async () => {
vi.useFakeTimers();
const pending = new PendingConfigSaves();
const failure = new Error("database write failed");
const save = async () => {
throw failure;
};
const first = pending.enqueue("plugin", save);
const second = pending.enqueue("plugin", save);
const outcomes = Promise.allSettled([first, second]);
await vi.advanceTimersByTimeAsync(100);
expect(await outcomes).toEqual([
{ status: "rejected", reason: failure },
{ status: "rejected", reason: failure },
]);
const retry = pending.enqueue("plugin", async () => {}, Promise.resolve());
await retry;
});
});
+14 -3
View File
@@ -20,10 +20,14 @@
MODE_SHINY,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { normalizePath } from "@/deps";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import {
HiddenFileSync,
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { $msg as translateMessage } from "@/common/translation";
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
@@ -139,6 +143,11 @@
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.deleteData(data);
}
function checkAutomaticSyncAvailable() {
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
return false;
}
function askMode(evt: MouseEvent, title: string, key: string) {
const menu = new Menu();
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
@@ -149,6 +158,7 @@
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
.onClick((e) => {
if (mode === MODE_AUTOMATIC) {
if (!checkAutomaticSyncAvailable()) return;
askOverwriteModeForAutomatic(evt, key);
} else {
setMode(key, mode as SYNC_MODE);
@@ -161,10 +171,11 @@
menu.showAtMouseEvent(evt);
}
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
if (!checkAutomaticSyncAvailable()) return;
setMode(key, MODE_AUTOMATIC);
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
}
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
const menu = new Menu();
@@ -0,0 +1,13 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
export function getAutomaticSyncTargetFiles(
key: string,
configDir: string,
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
): string[] {
const keys = key.startsWith("PLUGIN_ALL/")
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
: [key];
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
return [...files].map((file) => `${configDir}/${file}`);
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
return {
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
"PLUGIN_MAIN/example": {
key: "PLUGIN_MAIN/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
},
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/data.json"],
},
"PLUGIN_ETC/example": {
key: "PLUGIN_ETC/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/extra.json"],
},
"PLUGIN_MAIN/other": {
key: "PLUGIN_MAIN/other",
mode: MODE_AUTOMATIC,
files: ["plugins/other/main.js"],
},
};
}
describe("getAutomaticSyncTargetFiles", () => {
it("initialises a single selected item in the current configuration directory", () => {
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
".custom-config/app.json",
]);
});
it("initialises both saved child selections when the whole plug-in is selected", () => {
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
".obsidian/plugins/example/data.json",
".obsidian/plugins/example/main.js",
".obsidian/plugins/example/manifest.json",
]);
});
it("retains the available child files when the other child has no saved entry", () => {
const entries = createEntries();
delete entries["PLUGIN_DATA/example"];
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
".obsidian/plugins/example/main.js",
".obsidian/plugins/example/manifest.json",
]);
});
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
});
});
@@ -0,0 +1,260 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { HiddenFileSync } from "./CmdHiddenFileSync";
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
const path = ".obsidian/plugins/example/main.js" as FilePath;
const content = "module.exports = class ExamplePlugin {};";
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
async function getText(entry: Parameters<typeof readContent>[0]) {
const value = await readContent(entry);
return typeof value === "string" ? value : new TextDecoder().decode(value);
}
async function createHiddenFileSync() {
const fixture = await createOptionalFileSyncDatabase();
fixtureOwners.push(fixture);
fixture.settings.usePluginSync = false;
fixture.settings.syncInternalFilesTargetPatterns = "" as typeof fixture.settings.syncInternalFilesTargetPatterns;
fixture.settings.syncInternalFilesIgnorePatterns = "" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
let fileContent = content;
let stat: UXStat | null = {
ctime: 1_000,
mtime: 2_000,
size: fileContent.length,
type: "file",
};
const values = new Map<string, unknown>();
const kvDB = {
get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
set: vi.fn(async <T>(key: string, value: T) => {
values.set(key, value);
}),
};
const storageAccess = {
statHidden: vi.fn(async () => stat),
readHiddenFileAuto: vi.fn(async () => fileContent),
writeHiddenFileAuto: vi.fn(async () => undefined),
removeHidden: vi.fn(async () => true),
isExistsIncludeHidden: vi.fn(async () => true),
ensureDir: vi.fn(async () => undefined),
triggerHiddenFile: vi.fn(async () => undefined),
setFile(nextContent: string | null, mtime = 2_000) {
fileContent = nextContent ?? "";
stat = nextContent === null ? null : { ctime: 1_000, mtime, size: fileContent.length, type: "file" };
},
};
const databaseService = { isDatabaseReady: vi.fn(() => true) };
const appLifecycle = { isReady: vi.fn(() => false), isSuspended: vi.fn(() => true) };
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: fixture.settings,
localDatabase: fixture.localDatabase,
kvDB,
storageAccess,
services: {
...fixture.services,
database: databaseService,
appLifecycle,
path: {
...fixture.services.path,
markChangesAreSame: vi.fn(),
unmarkChanges: vi.fn(),
},
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
},
},
cacheFileRegExps: new Map(),
cacheCustomisationSyncIgnoredFiles: new Map(),
_fileInfoLastProcessed: new Map(),
_databaseInfoLastProcessed: new Map(),
_fileInfoLastKnown: new Map(),
semaphore: Semaphore(10),
_log: vi.fn(),
});
return { fixture, hiddenFileSync, storageAccess, kvDB, databaseService, appLifecycle };
}
afterEach(async () => {
vi.restoreAllMocks();
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
});
describe("Hidden File Sync optional save outcomes", () => {
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
const { fixture, hiddenFileSync } = await createHiddenFileSync();
await expect(hiddenFileSync.isTargetFile(path)).resolves.toBe(true);
vi.spyOn(fixture.localDatabase, "putDBEntry").mockRejectedValueOnce(new Error("temporary database failure"));
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(false);
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(true);
expect(fixture.localDatabase.putDBEntry).toHaveBeenCalledTimes(2);
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(saved).not.toBe(false);
});
it("rejects a failed strict write and retries the same fingerprint without delaying readiness", async () => {
const { fixture, hiddenFileSync, appLifecycle } = await createHiddenFileSync();
const putDBEntry = vi
.spyOn(fixture.localDatabase, "putDBEntry")
.mockRejectedValueOnce(new Error("temporary database failure"));
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
"temporary database failure"
);
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBeUndefined();
await expect(
hiddenFileSync._processOptionalFileEventWithResult(path, {
whenFlushRequested: new Promise<void>(() => undefined),
})
).resolves.toMatchObject({
kind: "completed",
});
expect(putDBEntry).toHaveBeenCalledTimes(2);
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(saved).not.toBe(false);
if (saved) expect(await getText(saved)).toBe(content);
expect(appLifecycle.isReady()).toBe(false);
expect(appLifecycle.isSuspended()).toBe(true);
});
it("rejects a false database-write result and can store a later edit after a same-content event", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
const putDBEntry = vi
.spyOn(fixture.localDatabase, "putDBEntry")
.mockImplementationOnce(async () => false as never);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
`Failed to store hidden file in the database: ${path}`
);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const first = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(first).not.toBe(false);
if (first) {
const firstRevision = first._rev;
hiddenFileSync._fileInfoLastProcessed.delete(path);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const unchanged = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(unchanged).not.toBe(false);
if (unchanged) expect(unchanged._rev).toBe(firstRevision);
}
storageAccess.setFile("module.exports = class UpdatedExamplePlugin {};", 3_000);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
const updated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(updated).not.toBe(false);
if (updated) expect(await getText(updated)).toBe("module.exports = class UpdatedExamplePlugin {};");
expect(putDBEntry).toHaveBeenCalledTimes(3);
});
it("vetoes when the physical database is unavailable and ignores disabled or filtered events", async () => {
const { fixture, hiddenFileSync, databaseService } = await createHiddenFileSync();
databaseService.isDatabaseReady.mockReturnValue(false);
await expect(
hiddenFileSync._processOptionalFileEventWithResult(path, {
whenFlushRequested: new Promise<void>(() => undefined),
})
).resolves.toMatchObject({ kind: "veto", value: expect.any(String) });
databaseService.isDatabaseReady.mockReturnValue(true);
fixture.settings.syncInternalFiles = false;
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "not-applicable",
});
fixture.settings.syncInternalFiles = true;
fixture.settings.syncInternalFilesTargetPatterns =
"^\\.obsidian/snippets/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "not-applicable",
});
});
it("treats an absent replayed deletion as complete and leaves cache initialisation intact", async () => {
const { hiddenFileSync, storageAccess, kvDB } = await createHiddenFileSync();
storageAccess.setFile(null);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
const lifecycle = hiddenFileSync as unknown as {
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
};
hiddenFileSync.core.settings.syncInternalFiles = false;
await lifecycle._everyOnDatabaseInitialized(false);
expect(kvDB.get).toHaveBeenCalledTimes(3);
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
});
it("rejects actual deletion failures and completes duplicate deletions without another write", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, content));
storageAccess.setFile(null);
const putRaw = vi
.spyOn(fixture.localDatabase, "putRaw")
.mockRejectedValueOnce(new Error("temporary deletion failure"));
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
"temporary deletion failure"
);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
hiddenFileSync.resetLastProcessedFile([path]);
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(putRaw).toHaveBeenCalledTimes(2);
});
it("recreates an empty hidden file when its database entry is logically deleted", async () => {
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, ""));
await expect(hiddenFileSync.deleteInternalFileOnDatabase(path)).resolves.toBe(true);
const deletedBase = await fixture.localDatabase.getDBEntry(
`i:${path}` as FilePath,
undefined,
false,
true,
true
);
expect(deletedBase).not.toBe(false);
if (deletedBase) expect(deletedBase.deleted).toBe(true);
if (!deletedBase) throw new Error("The logically deleted test entry could not be loaded");
vi.spyOn(fixture.localDatabase, "getDBEntry").mockResolvedValueOnce(deletedBase);
storageAccess.setFile("", 4_000);
const putDBEntry = vi.spyOn(fixture.localDatabase, "putDBEntry");
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
kind: "completed",
});
expect(putDBEntry).toHaveBeenCalledTimes(1);
const recreated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
expect(recreated).not.toBe(false);
if (recreated) {
expect(recreated.deleted).not.toBe(true);
expect(await getText(recreated)).toBe("");
}
});
});
+158 -11
View File
@@ -54,6 +54,8 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
@@ -69,6 +71,9 @@ type HiddenFileInitialisationProgress = {
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
function getComparingMTime(
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
includeDeleted = false
@@ -161,9 +166,7 @@ export class HiddenFileSync extends LiveSyncCommands {
// return true;
// }
private async _everyOnDatabaseInitialized(showNotice: boolean) {
this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed");
this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database");
this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown");
await this.ensureProcessedCaches();
if (this.isThisModuleEnabled()) {
if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) {
this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE);
@@ -249,6 +252,26 @@ export class HiddenFileSync extends LiveSyncCommands {
return false;
}
async _processOptionalFileEventWithResult(
path: FilePath,
_options?: OptionalFileEventOptions
): Promise<HandlerOutcome<void, string>> {
if (!this.isThisModuleEnabled()) return { kind: "not-applicable" };
if (!(await this.isTargetFile(path))) return { kind: "not-applicable" };
if (!this._isDatabaseReady()) {
return { kind: "veto", value: "The local database is not ready" };
}
await this.ensureProcessedCaches();
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) {
return { kind: "not-applicable" };
}
if (!(await this.trackStorageFileModificationStrict(path))) {
return { kind: "not-applicable" };
}
return { kind: "completed" };
}
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
if (isInternalMetadata(path)) {
this.queueConflictCheck(path);
@@ -317,6 +340,51 @@ export class HiddenFileSync extends LiveSyncCommands {
_fileInfoLastKnown!: MapLike<string, number>;
_databaseInfoLastProcessed!: MapLike<string, string>;
private processedCacheStore: unknown;
private processedCacheStoreReady = false;
private processedCacheLoadStore: unknown;
private processedCacheLoad?: Promise<void>;
private async ensureProcessedCaches() {
const store = this.kvDB;
if (!store) throw new Error("The Hidden File Sync cache store is unavailable");
if (this.processedCacheStoreReady && this.processedCacheStore === store) return;
if (this.processedCacheLoad && this.processedCacheLoadStore === store) {
await this.processedCacheLoad;
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
}
return;
}
let load!: Promise<void>;
load = Promise.all([
autosaveCache<string, string>(store, "hidden-file-lastProcessed"),
autosaveCache<string, string>(store, "hidden-file-lastProcessed-database"),
autosaveCache<string, number>(store, "hidden-file-lastKnown"),
])
.then(([fileInfoLastProcessed, databaseInfoLastProcessed, fileInfoLastKnown]) => {
if (this.processedCacheLoad !== load || this.kvDB !== store) return;
this._fileInfoLastProcessed = fileInfoLastProcessed;
this._databaseInfoLastProcessed = databaseInfoLastProcessed;
this._fileInfoLastKnown = fileInfoLastKnown;
this.processedCacheStore = store;
this.processedCacheStoreReady = true;
})
.finally(() => {
if (this.processedCacheLoad === load) {
this.processedCacheLoad = undefined;
this.processedCacheLoadStore = undefined;
}
});
this.processedCacheLoadStore = store;
this.processedCacheLoad = load;
await load;
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
}
}
statToKey(stat: UXStat | null) {
return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`;
}
@@ -600,7 +668,6 @@ Offline Changed files: ${processFiles.length}`;
this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
stat = cache.stat;
}
this.updateLastProcessedFile(path, stat!);
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
const nowIsNotFound = cache.deleted;
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
@@ -651,6 +718,27 @@ Offline Changed files: ${processFiles.length}`;
return true;
}
private async trackStorageFileModificationStrict(path: FilePath): Promise<boolean> {
return await this.serializedForEvent(path, async () => {
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) return false;
const stat = await this.core.storageAccess.statHidden(path);
if (stat != null && stat.type != "file") return true;
const key = await this.fileToStatKey(path, stat);
if (this.getLastProcessedFileKey(path) === key) {
this._log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
return true;
}
const fileInfo = await this.loadFileWithInfo(path);
if (fileInfo.deleted) {
await this.deleteInternalFileOnDatabaseStrict(path);
return true;
}
await this.storeInternalFileToDatabaseStrict(fileInfo);
return true;
});
}
// --> Conflict processing
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
@@ -1444,6 +1532,11 @@ Offline Changed files: ${files.length}`;
initialisationProgress?: HiddenFileInitialisationProgress
) {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
if (!this.isThisModuleEnabled()) {
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
return false;
}
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
// p.log("Resolving conflicts before starting...");
// await this.resolveConflictOnInternalFiles();
@@ -1494,12 +1587,25 @@ Offline Changed files: ${files.length}`;
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
p.done();
return true;
}
// <-- Initialization functions
// --> Storage To Database Functions
async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
return await this.loadBaseSaveData(file, includeContent, false);
}
private async loadBaseSaveDataStrict(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
return await this.loadBaseSaveData(file, includeContent, true);
}
private async loadBaseSaveData(
file: FilePath,
includeContent: boolean,
strict: boolean
): Promise<LoadedEntry | false> {
const prefixedFileName = addPrefix(file, ICHeader);
const id = await this.path2id(prefixedFileName, ICHeader);
try {
@@ -1525,6 +1631,7 @@ Offline Changed files: ${files.length}`;
return old;
}
} catch (ex) {
if (strict) throw ex;
this._log(`Getting base save data failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
@@ -1552,6 +1659,18 @@ Offline Changed files: ${files.length}`;
}
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
return await this.storeInternalFileToDatabaseCore(file, forceWrite, false);
}
private async storeInternalFileToDatabaseStrict(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
return await this.storeInternalFileToDatabaseCore(file, forceWrite, true);
}
private async storeInternalFileToDatabaseCore(
file: InternalFileInfo | UXFileInfo,
forceWrite: boolean,
strict: boolean
) {
const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path;
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
@@ -1565,9 +1684,11 @@ Offline Changed files: ${files.length}`;
if (fileInfo.deleted) {
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
}
const baseData = await this.__loadBaseSaveData(storeFilePath, true);
const baseData = strict
? await this.loadBaseSaveDataStrict(storeFilePath, true)
: await this.__loadBaseSaveData(storeFilePath, true);
if (baseData === false) throw new Error("Failed to load base data");
if (baseData._rev && !forceWrite) {
if (baseData._rev && !baseData.deleted && !baseData._deleted && !forceWrite) {
// Not newly created, we should check the content is actually modified.
const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body);
if (isSame) {
@@ -1592,11 +1713,15 @@ Offline Changed files: ${files.length}`;
this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat);
}
const success = ret && ret.ok;
if (!success && strict) {
throw new Error(`Failed to store hidden file in the database: ${storeFilePath}`);
}
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`);
return success;
} catch (ex) {
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (strict) throw ex;
return false;
}
});
@@ -1676,6 +1801,14 @@ Offline Changed files: ${files.length}`;
}
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, false);
}
private async deleteInternalFileOnDatabaseStrict(filenameSrc: FilePath, forceWrite = false) {
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, true);
}
private async deleteInternalFileOnDatabaseCore(filenameSrc: FilePath, _forceWrite: boolean, strict: boolean) {
const storeFilePath = filenameSrc;
const storageFilePath = filenameSrc;
const displayFileName = filenameSrc;
@@ -1686,18 +1819,26 @@ Offline Changed files: ${files.length}`;
}
return await serialized("file-" + prefixedFileName, async () => {
try {
const baseData = await this.__loadBaseSaveData(storeFilePath, false);
const baseData = strict
? await this.loadBaseSaveDataStrict(storeFilePath, false)
: await this.__loadBaseSaveData(storeFilePath, false);
if (baseData === false) throw new Error("Failed to load base data during deleting");
if (baseData._conflicts !== undefined) {
for (const conflictRev of baseData._conflicts) {
await this.localDatabase.removeRevision(baseData._id, conflictRev);
const removed = await this.localDatabase.removeRevision(baseData._id, conflictRev);
if (strict && removed === false) {
throw new Error(`Failed to remove conflicted hidden-file revision ${conflictRev}`);
}
this._log(
`STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`,
LOG_LEVEL_VERBOSE
);
}
}
if (baseData.deleted) {
const alreadyDeleted = strict
? !baseData._rev || baseData.deleted || baseData._deleted
: baseData.deleted;
if (alreadyDeleted) {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE);
this.updateLastProcessedDeletion(storeFilePath, baseData);
return true;
@@ -1718,11 +1859,13 @@ Offline Changed files: ${files.length}`;
return true;
} else {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
if (strict) throw new Error(`Failed to delete hidden file from the database: ${storeFilePath}`);
return false;
}
} catch (ex) {
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
if (strict) throw ex;
return false;
}
});
@@ -1925,6 +2068,7 @@ Offline Changed files: ${files.length}`;
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
let initialised = false;
let result: ConfigureHiddenFileSyncResult;
try {
result = await configureHiddenFileSyncMode(mode, {
@@ -1954,7 +2098,7 @@ Offline Changed files: ${files.length}`;
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialisationProgress = undefined;
},
});
@@ -1962,7 +2106,7 @@ Offline Changed files: ${files.length}`;
initialisationProgress?.done("Failed");
throw error;
}
if (result == "ignored" || result == "disabled") {
if (result == "ignored" || result == "disabled" || !initialised) {
return;
}
// this.plugin.settings.useAdvancedMode = true;
@@ -2084,6 +2228,9 @@ Offline Changed files: ${files.length}`;
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
this._processOptionalFileEventWithResult.bind(this)
);
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this));
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
}
describe("HiddenFileSync configuration-change notices", () => {
it.each(["pushForce", "pullForce", "safe"] as const)(
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
async (direction) => {
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const path = ".obsidian/example.json" as FilePath;
const operations = {
rebuildFromStorage: vi.fn(async () => [path]),
rebuildFromDatabase: vi.fn(async () => []),
rebuildMerging: vi.fn(async () => [path]),
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
scanAllStorageChanges: vi.fn(async () => undefined),
scanAllDatabaseChanges: vi.fn(async () => undefined),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
const settings = { syncInternalFiles: false };
const createProgress = vi.fn(() => progress);
const log = vi.fn();
Object.assign(hiddenFileSync, {
core: { settings },
_progress: createProgress,
_log: log,
...operations,
});
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
for (const operation of Object.values(operations)) {
expect(operation).not.toHaveBeenCalled();
}
expect(result).toBe(false);
expect(settings.syncInternalFiles).toBe(false);
expect(createProgress).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
expect(progress.done).not.toHaveBeenCalled();
}
);
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const createProgress = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: false } },
_progress: createProgress,
_log: vi.fn(),
});
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
expect(result).toBe(false);
expect(createProgress).not.toHaveBeenCalled();
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
});
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
const path = ".obsidian/example.json" as FilePath;
const deleted: MetaEntry = {
_id: "i:example" as DocumentID,
_rev: "2-deleted",
path: `i:${path}` as FilePathWithPrefix,
type: "plain",
ctime: 1,
mtime: 10,
size: 0,
deleted: true,
children: [],
eden: {},
};
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const deleteFile = vi.fn(async () => "OK" as const);
const updateDeletion = vi.fn();
const getFiles = vi.fn(async () => [deleted]);
const processedStorage = new Map([[path, "storage-processed"]]);
const processedDatabase = new Map([[path, "database-processed"]]);
const resetProcessed = vi.fn(() => processedDatabase.clear());
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: { syncInternalFiles: false },
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
},
_progress: vi.fn(() => progress),
_log: vi.fn(),
_verbose: vi.fn(),
_fileInfoLastProcessed: processedStorage,
_databaseInfoLastProcessed: processedDatabase,
getAllDatabaseFiles: getFiles,
getPath: (doc: MetaEntry) => doc.path,
resetLastProcessedDatabase: resetProcessed,
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
__deleteFile: deleteFile,
updateLastProcessedDeletion: updateDeletion,
adoptCurrentStorageFilesAsProcessed: vi.fn(),
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
scanAllStorageChanges: vi.fn(),
scanAllDatabaseChanges: vi.fn(),
});
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
expect(deleteFile).not.toHaveBeenCalled();
expect(updateDeletion).not.toHaveBeenCalled();
expect(getFiles).not.toHaveBeenCalled();
expect(resetProcessed).not.toHaveBeenCalled();
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
});
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{
id: string;
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
expect(command?.checkCallback?.(true)).toBe(false);
}
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(false);
expect(command?.checkCallback?.(false)).toBe(false);
}
settings.syncInternalFiles = true;
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
const scanAllDatabaseChanges = vi.fn(async () => undefined);
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: true } },
_progress: vi.fn(() => progress),
rebuildMerging,
adoptCurrentStorageFilesAsProcessed,
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
scanAllDatabaseChanges,
});
await hiddenFileSync.initialiseInternalFileSync("safe", true);
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
expect(result).toBe(true);
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(progress.done).toHaveBeenCalledOnce();
});
it.each(["pushForce", "pullForce", "safe"] as const)(
"retains the selected files and completes %s initialisation when enabled",
async (direction) => {
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const rebuildFromStorage = vi.fn(async () => files);
const rebuildFromDatabase = vi.fn(async () => entries);
const rebuildMerging = vi.fn(async () => files);
const adoptStorage = vi.fn();
const adoptDatabase = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { settings: { syncInternalFiles: true } },
_progress: vi.fn(() => progress),
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
rebuildFromStorage,
rebuildFromDatabase,
rebuildMerging,
adoptCurrentStorageFilesAsProcessed: adoptStorage,
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
scanAllStorageChanges: vi.fn(),
scanAllDatabaseChanges: vi.fn(),
});
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
expect(result).toBe(true);
if (direction === "pushForce") {
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
expect(rebuildFromDatabase).not.toHaveBeenCalled();
expect(rebuildMerging).not.toHaveBeenCalled();
} else if (direction === "pullForce") {
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
expect(rebuildFromStorage).not.toHaveBeenCalled();
expect(rebuildMerging).not.toHaveBeenCalled();
} else {
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
expect(rebuildFromStorage).not.toHaveBeenCalled();
expect(rebuildFromDatabase).not.toHaveBeenCalled();
}
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
expect(progress.done).toHaveBeenCalledOnce();
}
);
it("does not report completion when the initialisation entry point refuses to start", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
await handlers.initialise("safe");
return "enabled";
});
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: { services: { setting: { applyPartial: vi.fn() } } },
_progress: vi.fn(() => progress),
_log: log,
initialiseInternalFileSync: vi.fn(async () => false),
});
await hiddenFileSync.configureHiddenFileSync("MERGE");
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
});
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
const applyPartial = vi.fn(async () => {
events.push("apply-settings");
});
const initialiseInternalFileSync = vi.fn(async () => undefined);
const initialiseInternalFileSync = vi.fn(async () => true);
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
+8 -1
View File
@@ -1,4 +1,5 @@
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
setGetLanguage(getLanguage);
@@ -233,6 +234,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
void this._startUp();
}
override onunload(): void {
return void this.core.services.control.onUnload();
void this.core.services.control.onUnload().catch((error) => {
this.core.services.API.addLog(
"Failed to unload Self-hosted LiveSync: " + String(error),
LOG_LEVEL_URGENT,
"shutdown"
);
});
}
}
@@ -230,7 +230,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
this.isLastHidden = isHidden;
await this.services.fileProcessing.commitPendingFileEvents();
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
if (!pendingFileEventsCommitted) {
this._log(
"Pending file events remain because their commit did not complete before the window visibility changed.",
LOG_LEVEL_NOTICE
);
}
// Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
// window is hidden, instead of suspending on hide. On hide we skip the suspend for both
@@ -287,7 +293,10 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
if (this.settings.suspendFileWatching) return;
if (!this.settings.isConfigured) return;
if (!this.services.appLifecycle.isReady()) return;
await this.services.fileProcessing.commitPendingFileEvents();
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
if (!pendingFileEventsCommitted) {
return;
}
if (file == null) {
return;
}
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
type SetupOptions = {
settings?: Partial<typeof DEFAULT_SETTINGS>;
@@ -23,6 +24,8 @@ function setup(opts: SetupOptions) {
onResumed: vi.fn(async () => true),
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const replication = { replicateUnattendedByEvent: vi.fn(async () => undefined) };
const conflict = { queueCheckForIfOpen: vi.fn(async () => undefined) };
const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0);
@@ -39,6 +42,8 @@ function setup(opts: SetupOptions) {
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
replication,
conflict,
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
},
settings: {
@@ -61,6 +66,9 @@ function setup(opts: SetupOptions) {
module,
appLifecycle,
fileProcessing,
replication,
conflict,
api: core._services.API,
boundedRemoteActivityCount,
boundedLocalApplicationActivityCount,
};
@@ -303,6 +311,23 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
});
it("logs a failed pending-file commit and still suspends on hide", async () => {
const { module, appLifecycle, fileProcessing, api } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: true },
hidden: true,
});
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
expect(api.addLog).toHaveBeenCalledWith(
expect.stringContaining("Pending file events remain"),
LOG_LEVEL_NOTICE,
expect.any(String)
);
});
it("does not apply on mobile even if the flag is set", async () => {
const { module, appLifecycle } = setup({
settings: { keepReplicationActiveInBackground: true, liveSync: true },
@@ -313,3 +338,20 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
});
});
describe("watchWorkspaceOpenAsync — pending file event commit", () => {
it("skips replication and conflict checks when pending file events fail to commit", async () => {
const { module, fileProcessing, replication, conflict } = setup({
settings: { syncOnFileOpen: true },
hidden: false,
});
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
await module.watchWorkspaceOpenAsync({ path: "note.md" } as Parameters<
typeof module.watchWorkspaceOpenAsync
>[0]);
expect(replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
expect(conflict.queueCheckForIfOpen).not.toHaveBeenCalled();
});
});
@@ -171,20 +171,18 @@ export function paneSyncSettings(
}
});
void addPanel(
paneEl,
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"),
undefined,
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
).then((paneEl) => {
new Setting(paneEl).autoWireToggle("batchSave");
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
onUpdate: visibleOnly(batchEnabled),
});
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
acceptZero: true,
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
onUpdate: visibleOnly(batchEnabled),
});
});
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class {
autoWireDropDown(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireToggle(key: string, options = {}) {
controls.set(key, options);
return this;
}
autoWireNumeric(key: string, options = {}) {
controls.set(key, options);
return this;
}
},
}));
import { paneSyncSettings } from "./PaneSyncSettings";
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
const callbacks = new Map<string, (value: string) => Promise<void>>();
const panels = new Map<string, OnUpdateFunc | undefined>();
const tab = {
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
isConfiguredAs(key: string, value: unknown) {
if (key === "syncMode") {
return (
(this.editingSettings.liveSync
? "LIVESYNC"
: this.editingSettings.periodicReplication
? "PERIODIC"
: "ONEVENTS") === value
);
}
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
},
saveSettings: vi.fn(async () => {}),
requestCatalogueRefresh: vi.fn(),
};
const functions = {
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
then(callback: (element: HTMLElement) => void) {
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
panels.set(title, onUpdate);
callback({} as HTMLElement);
},
}),
} as unknown as PageFunctions;
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
return { tab, callbacks, panels };
}
function visible(key: string): boolean {
const control = controls.get(key);
if (!control) throw new Error(`Missing setting control: ${key}`);
return control.onUpdate?.().visibility !== false;
}
beforeEach(() => controls.clear());
describe("mode-specific database batching settings", () => {
it.each([
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
expect(visible("batchSave")).toBe(!settings.liveSync);
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
expect(visible("batchSaveMinimumDelay")).toBe(delays);
expect(visible("batchSaveMaximumDelay")).toBe(delays);
});
it.each([
[true, false],
[false, true],
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
const switchMode = callbacks.get("syncMode")!;
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
await switchMode(mode);
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
}
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
});
});