Fix Automatic mode prerequisites and plug-in initialisation targets

This commit is contained in:
vorotamoroz committed 2026-10-23 01:00:22 +00:00
1 parent ce0524d49f
commit e83833d4e8
12 files changed
+735 -19

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.",
"Preparing follow-up report…": "Preparing follow-up report…",
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
+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([]);
});
});
@@ -69,6 +69,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
@@ -1444,6 +1447,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,6 +1502,7 @@ Offline Changed files: ${files.length}`;
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
p.done();
return true;
}
// <-- Initialization functions
@@ -1925,6 +1934,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 +1964,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 +1972,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, {