mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 11:33:21 +00:00
Merge main and validate local saves with Commonlib 0.2.1
This commit is contained in:
commit
6d289a8824
26 files changed
+1858
-83
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -416,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;
|
||||
@@ -536,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);
|
||||
@@ -765,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--;
|
||||
@@ -875,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);
|
||||
@@ -892,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));
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -71,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
|
||||
@@ -1529,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();
|
||||
@@ -1579,6 +1587,7 @@ Offline Changed files: ${files.length}`;
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
p.done();
|
||||
return true;
|
||||
}
|
||||
// <-- Initialization functions
|
||||
|
||||
@@ -2059,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, {
|
||||
@@ -2088,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;
|
||||
},
|
||||
});
|
||||
@@ -2096,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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in new issue
Block a user