mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 18:43:20 +00:00
Refactor optional file synchronisation ownership
This commit is contained in:
1 parent
3b2d5aa5af
commit
826413bf84
67 files changed
+6294
-1856
No files matched your search
@@ -62,18 +62,6 @@ export class LiveSyncBaseCore<
|
||||
this.services.appLifecycle.onUnload.addHandler(() => Promise.resolve(addOn.onunload()).then(() => true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an add-on by its class name. Returns undefined if not found.
|
||||
* @param cls
|
||||
* @returns
|
||||
*/
|
||||
getAddOn<T extends TCommands>(cls: string) {
|
||||
for (const addon of this.addOns) {
|
||||
if (addon.constructor.name == cls) return addon as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
constructor(
|
||||
serviceHub: InjectableServiceHub<T>,
|
||||
serviceModuleInitialiser: (
|
||||
@@ -304,5 +292,4 @@ export class LiveSyncBaseCore<
|
||||
export interface IMinimumLiveSyncCommands {
|
||||
onunload(): void;
|
||||
onload(): void | Promise<void>;
|
||||
constructor: { name: string };
|
||||
}
|
||||
@@ -363,18 +363,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "新增连接",
|
||||
"zh-tw": "新增連線",
|
||||
},
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": {
|
||||
def: "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
|
||||
es: "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
|
||||
ko: "애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
|
||||
"zh-tw": "附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。",
|
||||
},
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": {
|
||||
def: "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.",
|
||||
es: "El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
|
||||
ko: "애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
|
||||
"zh-tw": "附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。",
|
||||
},
|
||||
Advanced: {
|
||||
def: "Advanced",
|
||||
es: "Avanzado",
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
"Active Remote Configuration": "Active Remote Configuration",
|
||||
"Add default patterns": "Add default patterns",
|
||||
"Add new connection": "Add new connection",
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.",
|
||||
"Advanced": "Advanced",
|
||||
"Advanced Settings": "Advanced Settings",
|
||||
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.",
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
"Active Remote Configuration": "Configuración remota activa",
|
||||
"Add default patterns": "Añadir patrones predeterminados",
|
||||
"Add new connection": "Añadir conexión",
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
|
||||
"Advanced": "Avanzado",
|
||||
"Advanced Settings": "Ajustes avanzados",
|
||||
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "Tras reiniciar, los datos de este dispositivo se subirán al servidor como «copia maestra». Ten en cuenta que cualquier dato no deseado que haya ahora en el servidor se sobrescribirá por completo.",
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
"Active Remote Configuration": "활성 원격 구성",
|
||||
"Add default patterns": "기본 패턴 추가",
|
||||
"Add new connection": "연결 추가",
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
|
||||
"Advanced": "고급",
|
||||
"Advanced Settings": "고급 설정",
|
||||
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "재시작하면 이 기기의 데이터가 '원본'으로서 서버에 업로드됩니다. 현재 서버에 있는 의도치 않은 데이터는 모두 완전히 덮어써진다는 점에 유의해 주세요.",
|
||||
|
||||
@@ -46,8 +46,6 @@
|
||||
"Active Remote Configuration": "目前啟用的遠端設定",
|
||||
"Add default patterns": "新增預設模式",
|
||||
"Add new connection": "新增連線",
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。",
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。",
|
||||
"Advanced": "進階",
|
||||
"Advanced Settings": "進階設定",
|
||||
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "重新啟動後,此裝置上的資料將以「主要複本」的形式上傳到伺服器。請注意,伺服器上任何非預期的現有資料都會被完全覆寫。",
|
||||
|
||||
@@ -58,12 +58,6 @@ Activate: Activate
|
||||
Active Remote Configuration: Active Remote Configuration
|
||||
Add default patterns: Add default patterns
|
||||
Add new connection: Add new connection
|
||||
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.:
|
||||
AddOn Module (ConfigSync) has not been loaded. This is very unexpected
|
||||
situation. Please report this issue.
|
||||
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.:
|
||||
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected
|
||||
situation. Please report this issue.
|
||||
Advanced: Advanced
|
||||
Advanced Settings: Advanced Settings
|
||||
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
|
||||
|
||||
@@ -57,12 +57,6 @@ Activate: Activar
|
||||
Active Remote Configuration: Configuración remota activa
|
||||
Add default patterns: Añadir patrones predeterminados
|
||||
Add new connection: Añadir conexión
|
||||
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.:
|
||||
El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy
|
||||
inesperada. Informa de este problema.
|
||||
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.:
|
||||
El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es
|
||||
muy inesperada. Informa de este problema.
|
||||
Advanced: Avanzado
|
||||
Advanced Settings: Ajustes avanzados
|
||||
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
|
||||
|
||||
@@ -47,8 +47,6 @@ Activate: 활성화
|
||||
Active Remote Configuration: 활성 원격 구성
|
||||
Add default patterns: 기본 패턴 추가
|
||||
Add new connection: 연결 추가
|
||||
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.: 애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.
|
||||
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.: 애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.
|
||||
Advanced: 고급
|
||||
Advanced Settings: 고급 설정
|
||||
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.: 재시작하면 이 기기의 데이터가 '원본'으로서 서버에 업로드됩니다. 현재 서버에 있는 의도치 않은 데이터는 모두 완전히 덮어써진다는 점에 유의해 주세요.
|
||||
|
||||
@@ -139,8 +139,6 @@ username: 使用者名稱
|
||||
"↑: Overwrite Remote": ↑:覆寫遠端
|
||||
"↓: Overwrite Local": ↓:覆寫本機
|
||||
"⇅: Use newer": ⇅:使用較新版本
|
||||
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.: 附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。
|
||||
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.: 附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。
|
||||
All the same or non-existent: 全部相同或不存在
|
||||
Apply All Selected: 套用所有已選取項目
|
||||
Automatic: 自動
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { 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 {},
|
||||
}));
|
||||
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||
LiveSyncCommands: class LiveSyncCommands {
|
||||
core!: { services: unknown };
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
},
|
||||
}));
|
||||
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 { cancelTask } from "@/common/utils.ts";
|
||||
import { ConfigSync } from "./CmdConfigSync";
|
||||
|
||||
describe("ConfigSync commands", () => {
|
||||
it("shows the Customisation Sync command only whilst the feature is enabled", () => {
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
}> = [];
|
||||
const settings = {
|
||||
usePluginSync: false,
|
||||
};
|
||||
const showPluginSyncModal = vi.fn();
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
settings,
|
||||
services: {
|
||||
API: {
|
||||
addCommand: vi.fn((command) => commands.push(command)),
|
||||
},
|
||||
},
|
||||
},
|
||||
addRibbonIcon: vi.fn(() => ({
|
||||
addClass: vi.fn(),
|
||||
})),
|
||||
showPluginSyncModal,
|
||||
});
|
||||
|
||||
configSync.onload();
|
||||
|
||||
const command = commands.find(({ id }) => id === "livesync-plugin-dialog-ex");
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
|
||||
settings.usePluginSync = true;
|
||||
expect(command?.checkCallback?.(true)).toBe(true);
|
||||
expect(command?.checkCallback?.(false)).toBe(true);
|
||||
expect(showPluginSyncModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cancels the pending configuration Notice before releasing its owned UI", () => {
|
||||
const notices = { hide: vi.fn() };
|
||||
const periodicPluginSweepProcessor = { disable: vi.fn() };
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
services: {
|
||||
context: { notices },
|
||||
},
|
||||
},
|
||||
periodicPluginSweepProcessor,
|
||||
});
|
||||
|
||||
configSync.onunload();
|
||||
|
||||
expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration");
|
||||
expect(notices.hide).toHaveBeenCalledWith("config-sync:updated-configuration");
|
||||
expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ConfigSync,
|
||||
PluginDataExDisplayV2,
|
||||
type IPluginDataExDisplay,
|
||||
type PluginDataExFile,
|
||||
} from "./CmdConfigSync.ts";
|
||||
import type { CustomisationSyncDialogView, IPluginDataExDisplay } from "./customisationSyncView.ts";
|
||||
import type { PluginDataExFile } from "./customisationSyncCodec.ts";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getDocData, timeDeltaToHumanReadable, unique } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
// import { askString } from "../../common/utils";
|
||||
import { Menu } from "@/deps.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
@@ -28,15 +22,9 @@
|
||||
) => Promise<boolean>;
|
||||
export let deleteData: (data: IPluginDataExDisplay) => Promise<boolean>;
|
||||
export let hidden: boolean;
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let customisationSync: CustomisationSyncDialogView;
|
||||
export let isMaintenanceMode: boolean = false;
|
||||
export let isFlagged: boolean = false;
|
||||
$: core = plugin.core;
|
||||
const addOn = plugin.core.getAddOn<ConfigSync>(ConfigSync.name)!;
|
||||
if (!addOn) {
|
||||
Logger(`Could not load the add-on ${ConfigSync.name}`, LOG_LEVEL_INFO);
|
||||
throw new Error(`Could not load the add-on ${ConfigSync.name}`);
|
||||
}
|
||||
|
||||
export let selected = "";
|
||||
let freshness = "";
|
||||
@@ -263,7 +251,7 @@
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
if (selectedItem && (await applyData(selectedItem))) {
|
||||
addOn.updatePluginList(true, local?.documentPath);
|
||||
void customisationSync.updatePluginList(true, local?.documentPath);
|
||||
}
|
||||
}
|
||||
async function compareSelected() {
|
||||
@@ -279,18 +267,12 @@
|
||||
if (local && remote) {
|
||||
if (!filename) {
|
||||
if (await compareData(local, remote)) {
|
||||
addOn.updatePluginList(true, local.documentPath);
|
||||
void customisationSync.updatePluginList(true, local.documentPath);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const localCopy =
|
||||
local instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(local) : { ...local };
|
||||
const remoteCopy =
|
||||
remote instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(remote) : { ...remote };
|
||||
localCopy.files = localCopy.files.filter((e) => e.filename == filename);
|
||||
remoteCopy.files = remoteCopy.files.filter((e) => e.filename == filename);
|
||||
if (await compareData(localCopy, remoteCopy, true)) {
|
||||
addOn.updatePluginList(true, local.documentPath);
|
||||
if (await customisationSync.compareFileUsingDisplayData(local, remote, filename)) {
|
||||
void customisationSync.updatePluginList(true, local.documentPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -333,7 +315,7 @@
|
||||
const selectedItem = list.find((e) => e.term == selected);
|
||||
// const deletedPath = selectedItem.documentPath;
|
||||
if (selectedItem && (await deleteData(selectedItem))) {
|
||||
addOn.reloadPluginList(true);
|
||||
void customisationSync.reloadPluginList(true);
|
||||
}
|
||||
}
|
||||
async function duplicateItem() {
|
||||
@@ -342,7 +324,7 @@
|
||||
Logger(`Could not find local item`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const duplicateTermName = await core.confirm.askString(
|
||||
const duplicateTermName = await customisationSync.askString(
|
||||
translateMessage("Duplicate"),
|
||||
translateMessage("device name"),
|
||||
""
|
||||
@@ -352,9 +334,7 @@
|
||||
Logger(translateMessage('We can not use "/" to the device name'), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const key = `${plugin.core.services.API.getSystemConfigDir()}/${local.files[0].filename}`;
|
||||
await addOn.storeCustomizationFiles(key as FilePath, duplicateTermName);
|
||||
await addOn.updatePluginList(false, addOn.filenameToUnifiedKey(key, duplicateTermName));
|
||||
await customisationSync.duplicateData(local, duplicateTermName);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { mount, unmount } from "svelte";
|
||||
import { App, Modal } from "@/deps.ts";
|
||||
import ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import { type App, Modal } from "@/deps.ts";
|
||||
import type { HiddenFileSyncInitialisationView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
|
||||
import type { CustomisationSyncDialogView } from "./customisationSyncView.ts";
|
||||
import PluginPane from "./PluginPane.svelte";
|
||||
export class PluginDialogModal extends Modal {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
customisationSync: CustomisationSyncDialogView;
|
||||
hiddenFileSync: HiddenFileSyncInitialisationView;
|
||||
component: ReturnType<typeof mount> | undefined;
|
||||
isOpened() {
|
||||
return this.component != undefined;
|
||||
}
|
||||
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
|
||||
constructor(
|
||||
app: App,
|
||||
customisationSync: CustomisationSyncDialogView,
|
||||
hiddenFileSync: HiddenFileSyncInitialisationView
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.customisationSync = customisationSync;
|
||||
this.hiddenFileSync = hiddenFileSync;
|
||||
}
|
||||
|
||||
override onOpen() {
|
||||
@@ -25,7 +32,10 @@ export class PluginDialogModal extends Modal {
|
||||
if (!this.component) {
|
||||
this.component = mount(PluginPane, {
|
||||
target: contentEl,
|
||||
props: { plugin: this.plugin, core: this.plugin.core },
|
||||
props: {
|
||||
customisationSync: this.customisationSync,
|
||||
hiddenFileSync: this.hiddenFileSync,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const svelteMocks = vi.hoisted(() => ({
|
||||
mount: vi.fn(),
|
||||
unmount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("svelte", () => svelteMocks);
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Modal: class Modal {
|
||||
contentEl = { setCssStyles: vi.fn() };
|
||||
titleEl = { setText: vi.fn() };
|
||||
},
|
||||
}));
|
||||
vi.mock("./PluginPane.svelte", () => ({ default: "PluginPane" }));
|
||||
|
||||
import { PluginDialogModal } from "./PluginDialogModal.ts";
|
||||
|
||||
describe("PluginDialogModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("mounts the focused views once and releases the component on close", () => {
|
||||
const component = { component: "customisation-sync" };
|
||||
const customisationSync = { catalogue: {} };
|
||||
const hiddenFileSync = { initialiseInternalFileSync: vi.fn() };
|
||||
svelteMocks.mount.mockReturnValue(component);
|
||||
|
||||
const modal = new PluginDialogModal({} as never, customisationSync as never, hiddenFileSync);
|
||||
modal.onOpen();
|
||||
modal.onOpen();
|
||||
|
||||
expect(svelteMocks.mount).toHaveBeenCalledOnce();
|
||||
expect(svelteMocks.mount).toHaveBeenCalledWith("PluginPane", {
|
||||
target: modal.contentEl,
|
||||
props: { customisationSync, hiddenFileSync },
|
||||
});
|
||||
expect(modal.isOpened()).toBe(true);
|
||||
|
||||
modal.onClose();
|
||||
|
||||
expect(svelteMocks.unmount).toHaveBeenCalledWith(component);
|
||||
expect(modal.isOpened()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import ObsidianLiveSyncPlugin from "@/main";
|
||||
import {
|
||||
ConfigSync,
|
||||
type IPluginDataExDisplay,
|
||||
pluginIsEnumerating,
|
||||
pluginList,
|
||||
pluginManifestStore,
|
||||
pluginV2Progress,
|
||||
} from "./CmdConfigSync.ts";
|
||||
import type { CustomisationSyncDialogView, IPluginDataExDisplay } from "./customisationSyncView.ts";
|
||||
import PluginCombo from "./PluginCombo.svelte";
|
||||
import { Menu, type PluginManifest } from "@/deps.ts";
|
||||
import { unique } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
@@ -19,38 +11,19 @@
|
||||
type SYNC_MODE,
|
||||
MODE_SHINY,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { normalizePath } from "@/deps";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import type { HiddenFileSyncInitialisationView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let core :LiveSyncBaseCore;
|
||||
// $: core = plugin.core;
|
||||
|
||||
export let customisationSync: CustomisationSyncDialogView;
|
||||
export let hiddenFileSync: HiddenFileSyncInitialisationView;
|
||||
|
||||
$: hideNotApplicable = false;
|
||||
$: thisTerm = core.services.setting.getDeviceAndVaultName();
|
||||
$: thisTerm = customisationSync.getDeviceAndVaultName();
|
||||
|
||||
const addOn = core.getAddOn<ConfigSync>(ConfigSync.name)!;
|
||||
if (!addOn) {
|
||||
const msg = translateMessage(
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue."
|
||||
);
|
||||
Logger(msg, LOG_LEVEL_NOTICE);
|
||||
throw new Error(msg);
|
||||
}
|
||||
const addOnHiddenFileSync = core.getAddOn<HiddenFileSync>(HiddenFileSync.name) as HiddenFileSync;
|
||||
if (!addOnHiddenFileSync) {
|
||||
const msg = translateMessage(
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue."
|
||||
);
|
||||
Logger(msg, LOG_LEVEL_NOTICE);
|
||||
throw new Error(msg);
|
||||
}
|
||||
const catalogue = customisationSync.catalogue;
|
||||
const enumerationActive = customisationSync.enumerationActive;
|
||||
const migrationProgress = customisationSync.migrationProgress;
|
||||
const manifests = customisationSync.manifests;
|
||||
|
||||
let list: IPluginDataExDisplay[] = [];
|
||||
|
||||
@@ -61,21 +34,17 @@
|
||||
let applyAllPluse = 0;
|
||||
let isMaintenanceMode = false;
|
||||
async function requestUpdate() {
|
||||
await addOn.updatePluginList(true);
|
||||
await customisationSync.updatePluginList(true);
|
||||
}
|
||||
async function requestReload() {
|
||||
await addOn.reloadPluginList(true);
|
||||
await customisationSync.reloadPluginList(true);
|
||||
}
|
||||
let allTerms = [] as string[];
|
||||
pluginList.subscribe((e) => {
|
||||
list = e;
|
||||
allTerms = unique(list.map((e) => e.term));
|
||||
});
|
||||
pluginIsEnumerating.subscribe((e) => {
|
||||
loading = e;
|
||||
});
|
||||
onMount(async () => {
|
||||
requestUpdate();
|
||||
let allTerms: string[] = [];
|
||||
$: list = $catalogue;
|
||||
$: allTerms = unique(list.map((entry) => entry.term));
|
||||
$: loading = $enumerationActive;
|
||||
onMount(() => {
|
||||
void requestUpdate();
|
||||
});
|
||||
|
||||
function filterList(list: IPluginDataExDisplay[], categories: string[]) {
|
||||
@@ -104,15 +73,11 @@
|
||||
SNIPPET: translateMessage("Snippets"),
|
||||
};
|
||||
async function scanAgain() {
|
||||
await addOn.scanAllConfigFiles(true);
|
||||
await customisationSync.scanAllConfigFiles(true);
|
||||
await requestUpdate();
|
||||
}
|
||||
async function replicate() {
|
||||
await core.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
await customisationSync.synchronise();
|
||||
}
|
||||
function selectAllNewest(selectMode: boolean) {
|
||||
selectNewestPulse++;
|
||||
@@ -126,17 +91,17 @@
|
||||
applyAllPluse++;
|
||||
}
|
||||
async function applyData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.applyData(data);
|
||||
return await customisationSync.applyData(data);
|
||||
}
|
||||
async function compareData(
|
||||
docA: IPluginDataExDisplay,
|
||||
docB: IPluginDataExDisplay,
|
||||
compareEach = false
|
||||
): Promise<boolean> {
|
||||
return await addOn.compareUsingDisplayData(docA, docB, compareEach);
|
||||
return await customisationSync.compareUsingDisplayData(docA, docB, compareEach);
|
||||
}
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.deleteData(data);
|
||||
return await customisationSync.deleteData(data);
|
||||
}
|
||||
function askMode(evt: MouseEvent, title: string, key: string) {
|
||||
const menu = new Menu();
|
||||
@@ -161,9 +126,8 @@
|
||||
}
|
||||
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
||||
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 = customisationSync.getConfiguredTargetFiles(key);
|
||||
void hiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
const menu = new Menu();
|
||||
@@ -196,7 +160,7 @@
|
||||
applyData,
|
||||
compareData,
|
||||
deleteData,
|
||||
plugin,
|
||||
customisationSync,
|
||||
isMaintenanceMode,
|
||||
};
|
||||
|
||||
@@ -236,22 +200,12 @@
|
||||
);
|
||||
if (mode == MODE_SELECTIVE) {
|
||||
automaticList.delete(key);
|
||||
delete plugin.core.settings.pluginSyncExtendedSetting[key];
|
||||
automaticListDisp = automaticList;
|
||||
} else {
|
||||
automaticList.set(key, mode);
|
||||
automaticListDisp = automaticList;
|
||||
if (!(key in plugin.core.settings.pluginSyncExtendedSetting)) {
|
||||
plugin.core.settings.pluginSyncExtendedSetting[key] = {
|
||||
key,
|
||||
mode,
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
plugin.core.settings.pluginSyncExtendedSetting[key].files = files;
|
||||
plugin.core.settings.pluginSyncExtendedSetting[key].mode = mode;
|
||||
}
|
||||
core.services.setting.saveSettingData();
|
||||
customisationSync.updateConfiguredMode(key, mode, files);
|
||||
}
|
||||
function getIcon(mode: SYNC_MODE) {
|
||||
if (mode in ICONS) {
|
||||
@@ -264,7 +218,7 @@
|
||||
let automaticListDisp = new Map<string, SYNC_MODE>();
|
||||
|
||||
// apply current configuration to the dialogue
|
||||
for (const { key, mode } of Object.values(plugin.core.settings.pluginSyncExtendedSetting)) {
|
||||
for (const { key, mode } of customisationSync.getConfiguredModes()) {
|
||||
automaticList.set(key, mode);
|
||||
}
|
||||
|
||||
@@ -273,7 +227,7 @@
|
||||
let displayKeys: Record<string, string[]> = {};
|
||||
|
||||
function computeDisplayKeys(list: IPluginDataExDisplay[]) {
|
||||
const extraKeys = Object.keys(plugin.core.settings.pluginSyncExtendedSetting);
|
||||
const extraKeys = customisationSync.getConfiguredModes().map(({ key }) => key);
|
||||
return [
|
||||
...list,
|
||||
...extraKeys
|
||||
@@ -303,7 +257,7 @@
|
||||
for (const item of deleteItems) {
|
||||
await deleteData(item);
|
||||
}
|
||||
addOn.reloadPluginList(true);
|
||||
void customisationSync.reloadPluginList(true);
|
||||
}
|
||||
|
||||
let nameMap = new Map<string, string>();
|
||||
@@ -324,7 +278,7 @@
|
||||
}
|
||||
nameMap = newMap;
|
||||
}
|
||||
$: updateNameMap($pluginManifestStore);
|
||||
$: updateNameMap($manifests);
|
||||
|
||||
let displayEntries = [] as [string, string][];
|
||||
$: {
|
||||
@@ -335,7 +289,7 @@
|
||||
$: {
|
||||
pluginEntries = groupBy(filterList(list, ["PLUGIN_MAIN", "PLUGIN_DATA", "PLUGIN_ETC"]), "name");
|
||||
}
|
||||
let useSyncPluginEtc = plugin.core.settings.usePluginEtc;
|
||||
let useSyncPluginEtc = customisationSync.isPluginEtcEnabled();
|
||||
</script>
|
||||
|
||||
<div class="buttonsWrap">
|
||||
@@ -357,8 +311,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading">
|
||||
{#if loading || $pluginV2Progress !== 0}
|
||||
<span>{translateMessage("Updating list...")}{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`}</span>
|
||||
{#if loading || $migrationProgress !== 0}
|
||||
<span
|
||||
>{translateMessage("Updating list...")}{$migrationProgress == 0 ? "" : ` (${$migrationProgress})`}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="list">
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const FIELD_DELIMITER = "\u200b";
|
||||
const LINE_DELIMITER = "\n";
|
||||
|
||||
export type PluginDataExFile = {
|
||||
filename: string;
|
||||
data: string[];
|
||||
mtime: number;
|
||||
size: number;
|
||||
version?: string;
|
||||
hash?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
export type PluginDataEx = {
|
||||
documentPath?: FilePathWithPrefix;
|
||||
category: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
term: string;
|
||||
files: PluginDataExFile[];
|
||||
version?: string;
|
||||
mtime: number;
|
||||
};
|
||||
|
||||
export type CustomisationSyncCodecDependencies = {
|
||||
digestHash(data: string[]): string;
|
||||
parseYaml(source: string): unknown;
|
||||
};
|
||||
|
||||
function splitWithDelimiters(sources: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const str of sources) {
|
||||
let startIndex = 0;
|
||||
const maxLen = str.length;
|
||||
let i = -1;
|
||||
let fieldIndex;
|
||||
let lineIndex;
|
||||
do {
|
||||
fieldIndex = str.indexOf(FIELD_DELIMITER, startIndex);
|
||||
lineIndex = str.indexOf(LINE_DELIMITER, startIndex);
|
||||
if (fieldIndex == -1 && lineIndex == -1) {
|
||||
break;
|
||||
}
|
||||
if (fieldIndex == -1) {
|
||||
i = lineIndex;
|
||||
} else if (lineIndex == -1) {
|
||||
i = fieldIndex;
|
||||
} else {
|
||||
i = fieldIndex < lineIndex ? fieldIndex : lineIndex;
|
||||
}
|
||||
result.push(str.slice(startIndex, i + 1));
|
||||
startIndex = i + 1;
|
||||
} while (i < maxLen);
|
||||
if (startIndex < maxLen) {
|
||||
result.push(str.slice(startIndex));
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the legacy trailing-empty-chunk behaviour.
|
||||
if (sources[sources.length - 1] == "") {
|
||||
result.push("");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getTokenizer(source: string[]) {
|
||||
const sources = splitWithDelimiters(source);
|
||||
sources[0] = sources[0].substring(1);
|
||||
let pos = 0;
|
||||
let lineRunOut = false;
|
||||
return {
|
||||
next(): string {
|
||||
if (lineRunOut) {
|
||||
return "";
|
||||
}
|
||||
if (pos >= sources.length) {
|
||||
return "";
|
||||
}
|
||||
const item = sources[pos];
|
||||
if (!item.endsWith(LINE_DELIMITER)) {
|
||||
pos++;
|
||||
} else {
|
||||
lineRunOut = true;
|
||||
}
|
||||
if (item.endsWith(FIELD_DELIMITER) || item.endsWith(LINE_DELIMITER)) {
|
||||
return item.substring(0, item.length - 1);
|
||||
}
|
||||
return item + this.next();
|
||||
},
|
||||
nextLine() {
|
||||
if (lineRunOut) {
|
||||
pos++;
|
||||
} else {
|
||||
while (!sources[pos].endsWith(LINE_DELIMITER)) {
|
||||
pos++;
|
||||
if (pos >= sources.length) break;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
lineRunOut = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeCustomFormat(source: string[]): PluginDataEx {
|
||||
const tokens = getTokenizer(source);
|
||||
const category = tokens.next();
|
||||
const name = tokens.next();
|
||||
const term = tokens.next();
|
||||
tokens.nextLine();
|
||||
const version = tokens.next();
|
||||
tokens.nextLine();
|
||||
const mtime = Number(tokens.next());
|
||||
tokens.nextLine();
|
||||
const result: PluginDataEx = {
|
||||
category,
|
||||
name,
|
||||
term,
|
||||
version,
|
||||
mtime,
|
||||
files: [],
|
||||
};
|
||||
let filename = "";
|
||||
do {
|
||||
filename = tokens.next();
|
||||
if (!filename) break;
|
||||
const displayName = tokens.next();
|
||||
const fileVersion = tokens.next();
|
||||
tokens.nextLine();
|
||||
const fileMtime = Number(tokens.next());
|
||||
const size = Number(tokens.next());
|
||||
const hash = tokens.next();
|
||||
tokens.nextLine();
|
||||
const data: string[] = [];
|
||||
let piece = "";
|
||||
do {
|
||||
piece = tokens.next();
|
||||
if (piece == "") break;
|
||||
data.push(piece);
|
||||
} while (piece != "");
|
||||
result.files.push({
|
||||
filename,
|
||||
displayName,
|
||||
version: fileVersion,
|
||||
mtime: fileMtime,
|
||||
size,
|
||||
data,
|
||||
hash,
|
||||
});
|
||||
tokens.nextLine();
|
||||
} while (filename);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createCustomisationSyncCodec(dependencies: CustomisationSyncCodecDependencies) {
|
||||
function serialize(data: PluginDataEx): string {
|
||||
// Group fields with similar entropy around newlines to retain the existing chunking characteristics.
|
||||
let result = ":";
|
||||
result += data.category + FIELD_DELIMITER + data.name + FIELD_DELIMITER + data.term + LINE_DELIMITER;
|
||||
result += (data.version ?? "") + LINE_DELIMITER;
|
||||
result += data.mtime + LINE_DELIMITER;
|
||||
for (const file of data.files) {
|
||||
result +=
|
||||
file.filename +
|
||||
FIELD_DELIMITER +
|
||||
(file.displayName ?? "") +
|
||||
FIELD_DELIMITER +
|
||||
(file.version ?? "") +
|
||||
LINE_DELIMITER;
|
||||
const hash = dependencies.digestHash(file.data ?? []);
|
||||
result += file.mtime + FIELD_DELIMITER + file.size + FIELD_DELIMITER + hash + LINE_DELIMITER;
|
||||
for (const piece of file.data ?? []) {
|
||||
result += piece + FIELD_DELIMITER;
|
||||
}
|
||||
result += LINE_DELIMITER;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function deserialize<T>(source: string[], defaultValue: T): T {
|
||||
try {
|
||||
if (source[0][0] == ":") {
|
||||
return deserializeCustomFormat(source) as T;
|
||||
}
|
||||
return JSON.parse(source.join("")) as T;
|
||||
} catch {
|
||||
try {
|
||||
return dependencies.parseYaml(source.join("")) as T;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dummyHead = serialize({
|
||||
category: "CONFIG",
|
||||
name: "migrated",
|
||||
files: [],
|
||||
mtime: 0,
|
||||
term: "-",
|
||||
displayName: "MIRAGED",
|
||||
});
|
||||
const dummyEnd = FIELD_DELIMITER + LINE_DELIMITER + "\u200c";
|
||||
|
||||
return { serialize, deserialize, dummyHead, dummyEnd };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
|
||||
|
||||
function createCodec() {
|
||||
const digestHash = vi.fn((data: string[]) => `digest:${data.join("|")}`);
|
||||
const parseYaml = vi.fn((_source: string): unknown => {
|
||||
throw new Error("Invalid YAML");
|
||||
});
|
||||
return {
|
||||
codec: createCustomisationSyncCodec({ digestHash, parseYaml }),
|
||||
digestHash,
|
||||
parseYaml,
|
||||
};
|
||||
}
|
||||
|
||||
const data: PluginDataEx = {
|
||||
category: "PLUGIN_DATA",
|
||||
name: "example",
|
||||
term: "device-a",
|
||||
version: "1.2.3",
|
||||
mtime: 123,
|
||||
files: [
|
||||
{
|
||||
filename: ".obsidian/plugins/example/data.json",
|
||||
displayName: "data.json",
|
||||
version: "2.0.0",
|
||||
mtime: 120,
|
||||
size: 6,
|
||||
data: ["YWJj", "ZGVm"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("compatibility: Customisation Sync codec", () => {
|
||||
it("preserves the existing custom wire format", () => {
|
||||
const { codec, digestHash } = createCodec();
|
||||
|
||||
expect(codec.serialize(data)).toBe(
|
||||
":PLUGIN_DATA\u200bexample\u200bdevice-a\n" +
|
||||
"1.2.3\n" +
|
||||
"123\n" +
|
||||
".obsidian/plugins/example/data.json\u200bdata.json\u200b2.0.0\n" +
|
||||
"120\u200b6\u200bdigest:YWJj|ZGVm\n" +
|
||||
"YWJj\u200bZGVm\u200b\n"
|
||||
);
|
||||
expect(digestHash).toHaveBeenCalledWith(["YWJj", "ZGVm"]);
|
||||
});
|
||||
|
||||
it("round-trips the custom format across arbitrary source chunks", () => {
|
||||
const { codec } = createCodec();
|
||||
const serialised = codec.serialize(data);
|
||||
const source = [serialised.slice(0, 13), serialised.slice(13, 47), serialised.slice(47), ""];
|
||||
|
||||
expect(codec.deserialize<PluginDataEx>(source, {} as PluginDataEx)).toEqual({
|
||||
...data,
|
||||
files: [
|
||||
{
|
||||
...data.files[0],
|
||||
hash: "digest:YWJj|ZGVm",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains JSON as the first legacy fallback", () => {
|
||||
const { codec, parseYaml } = createCodec();
|
||||
|
||||
expect(codec.deserialize(['{"value":1}'], { value: 0 })).toEqual({ value: 1 });
|
||||
expect(parseYaml).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the injected YAML parser after JSON parsing fails", () => {
|
||||
const parseYaml = vi.fn(() => ({ value: 2 }));
|
||||
const codec = createCustomisationSyncCodec({ digestHash: vi.fn(() => "hash"), parseYaml });
|
||||
|
||||
expect(codec.deserialize(["value: 2"], { value: 0 })).toEqual({ value: 2 });
|
||||
expect(parseYaml).toHaveBeenCalledWith("value: 2");
|
||||
});
|
||||
|
||||
it("returns the supplied default when every decoder rejects the input", () => {
|
||||
const { codec } = createCodec();
|
||||
const defaultValue = { retained: true };
|
||||
|
||||
expect(codec.deserialize([], defaultValue)).toBe(defaultValue);
|
||||
});
|
||||
|
||||
it("preserves the V2 migration sentinels", () => {
|
||||
const { codec } = createCodec();
|
||||
|
||||
expect(codec.dummyHead).toBe(":CONFIG\u200bmigrated\u200b-\n\n0\n");
|
||||
expect(codec.dummyEnd).toBe("\u200b\n\u200c");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
import { cancelTask } from "@/common/utils.ts";
|
||||
import { CustomisationSyncContext } from "./customisationSyncContext";
|
||||
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
|
||||
|
||||
describe("CustomisationSyncContext commands", () => {
|
||||
it("keeps the legacy dialogue methods as delegates to the host-owned UI", () => {
|
||||
const control = {
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
isOpen: vi.fn(),
|
||||
};
|
||||
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
|
||||
Object.assign(configSync, {
|
||||
dependencies: createCustomisationSyncTestDependencies({
|
||||
getUIControl: () => control,
|
||||
}),
|
||||
});
|
||||
|
||||
configSync.showPluginSyncModal();
|
||||
configSync.hidePluginSyncModal();
|
||||
|
||||
expect(control.open).toHaveBeenCalledOnce();
|
||||
expect(control.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("releases every owned processor and reactive subscription", () => {
|
||||
const hideConfigurationNotice = vi.fn();
|
||||
const publishScanCount = vi.fn();
|
||||
const periodicPluginSweepProcessor = { disable: vi.fn() };
|
||||
const pluginScanProcessor = { terminate: vi.fn() };
|
||||
const pluginScanProcessorV2 = { terminate: vi.fn() };
|
||||
const pluginScanningChanged = vi.fn();
|
||||
const offChanged = vi.fn();
|
||||
const setEnumerationActive = vi.fn();
|
||||
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
|
||||
Object.assign(configSync, {
|
||||
dependencies: createCustomisationSyncTestDependencies({
|
||||
hideConfigurationNotice,
|
||||
publishScanCount,
|
||||
}),
|
||||
periodicPluginSweepProcessor,
|
||||
pluginScanProcessor,
|
||||
pluginScanProcessorV2,
|
||||
pluginScanningChanged,
|
||||
scanProgress: { offChanged },
|
||||
enumerationActive: { set: setEnumerationActive },
|
||||
});
|
||||
|
||||
configSync.dispose();
|
||||
|
||||
expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration");
|
||||
expect(hideConfigurationNotice).toHaveBeenCalledOnce();
|
||||
expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce();
|
||||
expect(pluginScanProcessor.terminate).toHaveBeenCalledOnce();
|
||||
expect(pluginScanProcessorV2.terminate).toHaveBeenCalledOnce();
|
||||
expect(offChanged).toHaveBeenCalledWith(pluginScanningChanged);
|
||||
expect(setEnumerationActive).toHaveBeenCalledWith(false);
|
||||
expect(publishScanCount).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
|
||||
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
|
||||
|
||||
function createConfigSync(options: { useV2?: boolean; usePluginEtc?: boolean } = {}) {
|
||||
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
|
||||
Object.assign(configSync, {
|
||||
dependencies: createCustomisationSyncTestDependencies({
|
||||
getConfigDir: () => ".obsidian",
|
||||
getSettings: () => ({
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: options.useV2 ?? true,
|
||||
usePluginEtc: options.usePluginEtc ?? true,
|
||||
pluginSyncExtendedSetting: {},
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
watchInternalFileChanges: false,
|
||||
notifyPluginOrSettingUpdated: false,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
return configSync;
|
||||
}
|
||||
|
||||
describe("compatibility: Customisation Sync paths", () => {
|
||||
it.each([
|
||||
[".obsidian/app.json", "CONFIG"],
|
||||
[".obsidian/themes/minimal/manifest.json", "THEME"],
|
||||
[".obsidian/themes/minimal/theme.css", "THEME"],
|
||||
[".obsidian/snippets/example.css", "SNIPPET"],
|
||||
[".obsidian/plugins/example/manifest.json", "PLUGIN_MAIN"],
|
||||
[".obsidian/plugins/example/main.js", "PLUGIN_MAIN"],
|
||||
[".obsidian/plugins/example/styles.css", "PLUGIN_MAIN"],
|
||||
[".obsidian/plugins/example/data.json", "PLUGIN_DATA"],
|
||||
[".obsidian/plugins/example/other.json", "PLUGIN_ETC"],
|
||||
[".obsidian/workspace", ""],
|
||||
["notes/example.json", "CONFIG"],
|
||||
])("classifies %s as %s", (path, expected) => {
|
||||
expect(createConfigSync().getFileCategory(path)).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps other plug-in files outside V1 and disabled plug-in-extra synchronisation", () => {
|
||||
const v1 = createConfigSync({ useV2: false });
|
||||
const withoutPluginEtc = createConfigSync({ usePluginEtc: false });
|
||||
const path = ".obsidian/plugins/example/other.json";
|
||||
|
||||
expect(v1.getFileCategory(path)).toBe("");
|
||||
expect(withoutPluginEtc.getFileCategory(path)).toBe("");
|
||||
});
|
||||
|
||||
it("recognises only classified files below the Obsidian configuration directory", () => {
|
||||
const configSync = createConfigSync();
|
||||
|
||||
expect(configSync.isTargetPath(".obsidian/app.json")).toBe(true);
|
||||
expect(configSync.isTargetPath(".obsidian/plugins/example/main.js")).toBe(true);
|
||||
expect(configSync.isTargetPath(".obsidian/workspace")).toBe(false);
|
||||
expect(configSync.isTargetPath("notes/example.json")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".obsidian/app.json", "ix:device-a/CONFIG/app.json.md"],
|
||||
[".obsidian/snippets/example.css", "ix:device-a/SNIPPET/example.css.md"],
|
||||
[".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example.md"],
|
||||
[".obsidian/plugins/example/data.json", "ix:device-a/PLUGIN_DATA/example.md"],
|
||||
])("creates the V1 document path for %s", (path, expected) => {
|
||||
expect(createConfigSync().filenameToUnifiedKey(path)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".obsidian/app.json", "ix:device-a/CONFIG/app.json%app.json"],
|
||||
[".obsidian/snippets/example.css", "ix:device-a/SNIPPET/example.css%example.css"],
|
||||
[".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example%main.js"],
|
||||
[".obsidian/plugins/example/data.json", "ix:device-a/PLUGIN_DATA/example%data.json"],
|
||||
])("creates the V2 document path for %s", (path, expected) => {
|
||||
expect(createConfigSync().filenameWithUnifiedKey(path)).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses an explicit device name when supplied", () => {
|
||||
const configSync = createConfigSync();
|
||||
|
||||
expect(configSync.filenameToUnifiedKey(".obsidian/app.json", "device-b")).toBe(
|
||||
"ix:device-b/CONFIG/app.json.md"
|
||||
);
|
||||
expect(configSync.filenameWithUnifiedKey(".obsidian/app.json", "device-b")).toBe(
|
||||
"ix:device-b/CONFIG/app.json%app.json"
|
||||
);
|
||||
expect(configSync.unifiedKeyPrefixOfTerminal("device-b")).toBe("ix:device-b/");
|
||||
});
|
||||
|
||||
it("parses a V2 document path and derives its V1 compatibility path", () => {
|
||||
expect(
|
||||
createConfigSync().parseUnifiedPath("ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix)
|
||||
).toEqual({
|
||||
device: "device-a",
|
||||
category: "PLUGIN_MAIN",
|
||||
key: "example",
|
||||
filename: "main.js",
|
||||
pathV1: "ix:device-a/PLUGIN_MAIN/example.md",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import { scheduleTask } from "@/common/utils.ts";
|
||||
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
|
||||
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
|
||||
|
||||
const PATH = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
|
||||
function createConfigSync(options: { ready?: boolean; suspended?: boolean; enabled?: boolean; owned?: boolean } = {}) {
|
||||
const settings = {
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
usePluginEtc: true,
|
||||
pluginSyncExtendedSetting: {},
|
||||
};
|
||||
const ownsLocalFile = vi.fn(() => options.owned ?? true);
|
||||
const statHidden = vi.fn(async () => ({ type: "file", mtime: 1 }));
|
||||
const recentProcessedInternalFiles = Object.assign([] as string[], {
|
||||
contains: vi.fn(() => false),
|
||||
});
|
||||
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
|
||||
Object.assign(configSync, {
|
||||
dependencies: createCustomisationSyncTestDependencies({
|
||||
getConfigDir: () => ".obsidian",
|
||||
getSettings: () => settings as never,
|
||||
storageAccess: { statHidden } as never,
|
||||
ownsLocalFile,
|
||||
}),
|
||||
_isMainReady: vi.fn(() => options.ready ?? true),
|
||||
_isMainSuspended: vi.fn(() => options.suspended ?? false),
|
||||
isThisModuleEnabled: vi.fn(() => options.enabled ?? true),
|
||||
recentProcessedInternalFiles,
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return { configSync, ownsLocalFile, statHidden };
|
||||
}
|
||||
|
||||
describe("Customisation Sync raw-event admission", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("schedules a recognised path granted by the composition owner", async () => {
|
||||
const { configSync, ownsLocalFile } = createConfigSync();
|
||||
|
||||
await expect(configSync._anyProcessOptionalFileEvent(PATH)).resolves.toBe(true);
|
||||
expect(ownsLocalFile).toHaveBeenCalledWith(PATH);
|
||||
expect(scheduleTask).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects an event while the host is not ready", async () => {
|
||||
const { configSync, statHidden } = createConfigSync({ ready: false });
|
||||
|
||||
await expect(configSync._anyProcessOptionalFileEvent(PATH)).resolves.toBe(false);
|
||||
expect(statHidden).not.toHaveBeenCalled();
|
||||
expect(scheduleTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a path outside the recognised Customisation Sync categories", async () => {
|
||||
const { configSync, ownsLocalFile } = createConfigSync();
|
||||
|
||||
await expect(configSync._anyProcessOptionalFileEvent(".obsidian/workspace" as FilePath)).resolves.toBe(false);
|
||||
expect(ownsLocalFile).not.toHaveBeenCalled();
|
||||
expect(scheduleTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a recognised path assigned to another owner", async () => {
|
||||
const { configSync, statHidden } = createConfigSync({ owned: false });
|
||||
|
||||
await expect(configSync._anyProcessOptionalFileEvent(PATH)).resolves.toBe(false);
|
||||
expect(statHidden).not.toHaveBeenCalled();
|
||||
expect(scheduleTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["suspended", { suspended: true }],
|
||||
["disabled", { enabled: false }],
|
||||
] as const)("rejects an event while %s", async (_label, options) => {
|
||||
const { configSync } = createConfigSync(options);
|
||||
|
||||
await expect(configSync._anyProcessOptionalFileEvent(PATH)).resolves.toBe(false);
|
||||
expect(scheduleTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
|
||||
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
|
||||
|
||||
const PATH = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
const V1_PATH = "ix:device-a/PLUGIN_DATA/example.md" as FilePathWithPrefix;
|
||||
const V2_PATH = "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix;
|
||||
|
||||
function asyncEntries(entries: object[]) {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* entries;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConfigSync(options: {
|
||||
useV2: boolean;
|
||||
localFiles?: FilePath[];
|
||||
databasePaths?: FilePathWithPrefix[];
|
||||
ownsLocalFile?: boolean;
|
||||
ownsLocalDocument?: boolean;
|
||||
}) {
|
||||
const storeCustomizationFiles = vi.fn(async () => true);
|
||||
const storeCustomisationFileV2 = vi.fn(async () => true);
|
||||
const deleteConfigOnDatabase = vi.fn(async () => true);
|
||||
const ownsLocalFile = vi.fn(() => options.ownsLocalFile ?? true);
|
||||
const ownsLocalDocument = vi.fn(() => options.ownsLocalDocument ?? true);
|
||||
const databaseEntries = (options.databasePaths ?? []).map((path) => ({ _id: path, path }));
|
||||
const localDatabase = {
|
||||
findEntries: vi.fn(() => asyncEntries(databaseEntries)),
|
||||
allDocsRaw: vi.fn(async () => ({
|
||||
rows: databaseEntries.map((doc) => ({ doc: { ...doc, deleted: false } })),
|
||||
})),
|
||||
};
|
||||
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
|
||||
Object.assign(configSync, {
|
||||
dependencies: createCustomisationSyncTestDependencies({
|
||||
getConfigDir: () => ".obsidian",
|
||||
getSettings: () => ({
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: options.useV2,
|
||||
usePluginEtc: true,
|
||||
pluginSyncExtendedSetting: {},
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
watchInternalFileChanges: false,
|
||||
notifyPluginOrSettingUpdated: false,
|
||||
}),
|
||||
getLocalDatabase: () => localDatabase as never,
|
||||
ownsLocalFile,
|
||||
ownsLocalDocument,
|
||||
}),
|
||||
scanInternalFiles: vi.fn(async () => options.localFiles ?? []),
|
||||
filenameToUnifiedKey: vi.fn(() => V1_PATH),
|
||||
filenameWithUnifiedKey: vi.fn(() => V2_PATH),
|
||||
unifiedKeyPrefixOfTerminal: vi.fn(() => "ix:device-a/"),
|
||||
getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path),
|
||||
storeCustomizationFiles,
|
||||
storeCustomisationFileV2,
|
||||
deleteConfigOnDatabase,
|
||||
updatePluginList: vi.fn(async () => undefined),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return {
|
||||
configSync,
|
||||
deleteConfigOnDatabase,
|
||||
ownsLocalDocument,
|
||||
ownsLocalFile,
|
||||
storeCustomisationFileV2,
|
||||
storeCustomizationFiles,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Customisation Sync scan ownership", () => {
|
||||
it.each([
|
||||
["V1", false],
|
||||
["V2", true],
|
||||
] as const)("does not store a %s local file assigned to another owner", async (_label, useV2) => {
|
||||
const fixture = createConfigSync({ useV2, localFiles: [PATH], ownsLocalFile: false });
|
||||
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.ownsLocalFile).toHaveBeenCalledWith(PATH);
|
||||
expect(fixture.storeCustomizationFiles).not.toHaveBeenCalled();
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create a deletion for an existing V1 document which is no longer locally owned", async () => {
|
||||
const fixture = createConfigSync({
|
||||
useV2: false,
|
||||
databasePaths: [V1_PATH],
|
||||
ownsLocalDocument: false,
|
||||
});
|
||||
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.ownsLocalDocument).toHaveBeenCalledWith(V1_PATH);
|
||||
expect(fixture.deleteConfigOnDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { get } from "svelte/store";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {
|
||||
disable = vi.fn();
|
||||
enable = vi.fn();
|
||||
},
|
||||
}));
|
||||
vi.mock("octagonal-wheels/concurrency/processor", () => ({
|
||||
QueueProcessor: class QueueProcessor {
|
||||
clearQueue = vi.fn();
|
||||
enqueue = vi.fn();
|
||||
terminate = vi.fn();
|
||||
startPipeline() {
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
|
||||
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
|
||||
|
||||
describe("CustomisationSyncContext state ownership", () => {
|
||||
it("does not share catalogue or presentation state between context instances", () => {
|
||||
const first = new CustomisationSyncContext(createCustomisationSyncTestDependencies());
|
||||
const second = new CustomisationSyncContext(createCustomisationSyncTestDependencies());
|
||||
|
||||
expect(first.catalogue).not.toBe(second.catalogue);
|
||||
expect(first.enumerationActive).not.toBe(second.enumerationActive);
|
||||
expect(first.migrationProgress).not.toBe(second.migrationProgress);
|
||||
expect(first.manifests).not.toBe(second.manifests);
|
||||
expect(get(first.manifests)).not.toBe(get(second.manifests));
|
||||
});
|
||||
});
|
||||
+375
-548
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
import type { CustomisationSyncContextDependencies } from "./customisationSyncContext.ts";
|
||||
|
||||
/** Minimal inert dependency set for focused Customisation Sync unit tests. */
|
||||
export function createCustomisationSyncTestDependencies(
|
||||
overrides: Partial<CustomisationSyncContextDependencies> = {}
|
||||
): CustomisationSyncContextDependencies {
|
||||
const defaults = {
|
||||
getSettings: () => ({
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
usePluginEtc: true,
|
||||
pluginSyncExtendedSetting: {},
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
watchInternalFileChanges: false,
|
||||
notifyPluginOrSettingUpdated: false,
|
||||
}),
|
||||
getLocalDatabase: () => ({}),
|
||||
storageAccess: {},
|
||||
path: {},
|
||||
log: () => undefined,
|
||||
getConfigDir: () => ".config-dir",
|
||||
getDeviceAndVaultName: () => "device-a",
|
||||
setDeviceAndVaultName: () => undefined,
|
||||
saveSettingData: () => Promise.resolve(),
|
||||
applySettings: () => Promise.resolve(),
|
||||
replicateUserInitiated: () => Promise.resolve(),
|
||||
askString: () => Promise.resolve(false),
|
||||
isReady: () => true,
|
||||
isSuspended: () => false,
|
||||
askRestart: () => undefined,
|
||||
createPeriodicProcessor: () => ({
|
||||
enable: () => undefined,
|
||||
disable: () => undefined,
|
||||
}),
|
||||
listFiles: () => Promise.resolve({ files: [], folders: [] }),
|
||||
resolveJsonConflict: () => Promise.resolve(false),
|
||||
selectTextFile: () => Promise.resolve(false),
|
||||
reloadPlugin: () => Promise.resolve(),
|
||||
getFallbackDeviceName: () => "desktop-test",
|
||||
showConfigurationNotice: () => undefined,
|
||||
hideConfigurationNotice: () => undefined,
|
||||
getUIControl: () => undefined,
|
||||
ownsLocalFile: () => true,
|
||||
ownsLocalDocument: () => true,
|
||||
publishScanCount: () => undefined,
|
||||
} as unknown as CustomisationSyncContextDependencies;
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type FilePathWithPrefix,
|
||||
MODE_AUTOMATIC,
|
||||
MODE_SELECTIVE,
|
||||
type PluginSyncSettingEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
|
||||
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
|
||||
import type { IPluginDataExDisplay } from "./customisationSyncView.ts";
|
||||
|
||||
function createConfigSync() {
|
||||
const saveSettingData = vi.fn(async () => undefined);
|
||||
const replicateUserInitiated = vi.fn(async () => undefined);
|
||||
const askString = vi.fn(async () => "device-b" as string | false);
|
||||
const pluginSyncExtendedSetting: Record<string, PluginSyncSettingEntry> = {
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/data.json"],
|
||||
},
|
||||
};
|
||||
const settings = {
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
usePluginEtc: true,
|
||||
pluginSyncExtendedSetting,
|
||||
};
|
||||
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
|
||||
Object.assign(configSync, {
|
||||
dependencies: createCustomisationSyncTestDependencies({
|
||||
getConfigDir: () => ".obsidian",
|
||||
getSettings: () => settings as never,
|
||||
saveSettingData,
|
||||
replicateUserInitiated: replicateUserInitiated as never,
|
||||
askString,
|
||||
}),
|
||||
});
|
||||
return { askString, configSync, replicateUserInitiated, saveSettingData, settings };
|
||||
}
|
||||
|
||||
const display = {
|
||||
documentPath: "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix,
|
||||
category: "PLUGIN_DATA",
|
||||
name: "example",
|
||||
term: "device-a",
|
||||
files: [
|
||||
{ filename: "data.json", data: ["a"], mtime: 1, size: 1 },
|
||||
{ filename: "other.json", data: ["b"], mtime: 2, size: 1 },
|
||||
],
|
||||
mtime: 2,
|
||||
} satisfies IPluginDataExDisplay;
|
||||
|
||||
describe("CustomisationSyncContext dialogue view", () => {
|
||||
it("projects and updates Customisation Sync modes without exposing mutable settings", () => {
|
||||
const { configSync, saveSettingData, settings } = createConfigSync();
|
||||
|
||||
const projected = configSync.getConfiguredModes();
|
||||
projected[0].files.push("changed-in-view");
|
||||
|
||||
expect(settings.pluginSyncExtendedSetting["PLUGIN_DATA/example"].files).toEqual(["plugins/example/data.json"]);
|
||||
expect(configSync.getConfiguredTargetFiles("PLUGIN_DATA/example")).toEqual([
|
||||
".obsidian/plugins/example/data.json",
|
||||
]);
|
||||
|
||||
configSync.updateConfiguredMode("PLUGIN_MAIN/example", MODE_AUTOMATIC, ["plugins/example/main.js"]);
|
||||
expect(settings.pluginSyncExtendedSetting["PLUGIN_MAIN/example"]).toEqual({
|
||||
key: "PLUGIN_MAIN/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/main.js"],
|
||||
});
|
||||
|
||||
configSync.updateConfiguredMode("PLUGIN_MAIN/example", MODE_SELECTIVE, []);
|
||||
expect(settings.pluginSyncExtendedSetting).not.toHaveProperty("PLUGIN_MAIN/example");
|
||||
expect(saveSettingData).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("routes host operations through the focused view", async () => {
|
||||
const { askString, configSync, replicateUserInitiated } = createConfigSync();
|
||||
|
||||
await configSync.synchronise();
|
||||
await expect(configSync.askString("Duplicate", "device name", "")).resolves.toBe("device-b");
|
||||
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
expect(askString).toHaveBeenCalledWith("Duplicate", "device name", "");
|
||||
});
|
||||
|
||||
it("keeps file-level comparison clones and duplication inside the view boundary", async () => {
|
||||
const { configSync } = createConfigSync();
|
||||
const compareUsingDisplayData = vi.fn<
|
||||
(dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach?: boolean) => Promise<boolean>
|
||||
>(async () => true);
|
||||
const storeCustomizationFiles = vi.fn(async () => true);
|
||||
const updatePluginList = vi.fn(async () => undefined);
|
||||
Object.assign(configSync, {
|
||||
compareUsingDisplayData,
|
||||
filenameToUnifiedKey: vi.fn(() => "ix:device-b/PLUGIN_DATA/example.md"),
|
||||
storeCustomizationFiles,
|
||||
updatePluginList,
|
||||
});
|
||||
|
||||
await expect(configSync.compareFileUsingDisplayData(display, display, "data.json")).resolves.toBe(true);
|
||||
const [left, right, compareEach] = compareUsingDisplayData.mock.calls[0];
|
||||
expect(left.files.map((file) => file.filename)).toEqual(["data.json"]);
|
||||
expect(right.files.map((file) => file.filename)).toEqual(["data.json"]);
|
||||
expect(compareEach).toBe(true);
|
||||
expect(display.files).toHaveLength(2);
|
||||
|
||||
await configSync.duplicateData(display, "device-b");
|
||||
expect(storeCustomizationFiles).toHaveBeenCalledWith(".obsidian/data.json", "device-b");
|
||||
expect(updatePluginList).toHaveBeenCalledWith(false, "ix:device-b/PLUGIN_DATA/example.md");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ICXHeader } from "@/common/types.ts";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
export type CustomisationSyncFileCategory =
|
||||
| "CONFIG"
|
||||
| "THEME"
|
||||
| "SNIPPET"
|
||||
| "PLUGIN_MAIN"
|
||||
| "PLUGIN_ETC"
|
||||
| "PLUGIN_DATA"
|
||||
| "";
|
||||
|
||||
export type CustomisationSyncPathOptions = {
|
||||
configDir: string;
|
||||
useV2: boolean;
|
||||
usePluginEtc: boolean;
|
||||
};
|
||||
|
||||
export function getCustomisationSyncFileCategory(
|
||||
filePath: string,
|
||||
options: CustomisationSyncPathOptions
|
||||
): CustomisationSyncFileCategory {
|
||||
if (filePath.split("/").length == 2 && filePath.endsWith(".json")) return "CONFIG";
|
||||
if (filePath.split("/").length == 4 && filePath.startsWith(`${options.configDir}/themes/`)) return "THEME";
|
||||
if (filePath.startsWith(`${options.configDir}/snippets/`) && filePath.endsWith(".css")) return "SNIPPET";
|
||||
if (filePath.startsWith(`${options.configDir}/plugins/`)) {
|
||||
if (filePath.endsWith("/styles.css") || filePath.endsWith("/manifest.json") || filePath.endsWith("/main.js")) {
|
||||
return "PLUGIN_MAIN";
|
||||
}
|
||||
if (filePath.endsWith("/data.json")) {
|
||||
return "PLUGIN_DATA";
|
||||
}
|
||||
return options.useV2 && options.usePluginEtc ? "PLUGIN_ETC" : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function isCustomisationSyncTargetPath(filePath: string, options: CustomisationSyncPathOptions): boolean {
|
||||
if (!filePath.startsWith(options.configDir)) return false;
|
||||
return getCustomisationSyncFileCategory(filePath, options) != "";
|
||||
}
|
||||
|
||||
export function getCustomisationSyncSettingKey(
|
||||
filePath: string,
|
||||
options: CustomisationSyncPathOptions
|
||||
): string | undefined {
|
||||
if (!isCustomisationSyncTargetPath(filePath, options)) return undefined;
|
||||
const category = getCustomisationSyncFileCategory(filePath, options);
|
||||
const name =
|
||||
category == "CONFIG" || category == "SNIPPET"
|
||||
? filePath.split("/").slice(-1)[0]
|
||||
: filePath.split("/").slice(-2)[0];
|
||||
return name ? `${category}/${name}` : undefined;
|
||||
}
|
||||
|
||||
export function getCustomisationSyncSettingKeyFromDocumentPath(documentPath: FilePathWithPrefix): string | undefined {
|
||||
const [, category, ...rest] = stripAllPrefixes(documentPath).split("/");
|
||||
if (!category || rest.length == 0) return undefined;
|
||||
if (!["CONFIG", "THEME", "SNIPPET", "PLUGIN_MAIN", "PLUGIN_ETC", "PLUGIN_DATA"].includes(category)) {
|
||||
return undefined;
|
||||
}
|
||||
const encodedName = category == "CONFIG" || category == "SNIPPET" ? rest.join("/") : rest[0];
|
||||
const name = encodedName.split("%")[0].replace(/\.md$/, "");
|
||||
return name ? `${category}/${name}` : undefined;
|
||||
}
|
||||
|
||||
export function createCustomisationSyncV1DocumentPath(
|
||||
filePath: string,
|
||||
device: string,
|
||||
options: CustomisationSyncPathOptions
|
||||
): FilePathWithPrefix {
|
||||
const category = getCustomisationSyncFileCategory(filePath, options);
|
||||
const name =
|
||||
category == "CONFIG" || category == "SNIPPET"
|
||||
? filePath.split("/").slice(-1)[0]
|
||||
: category == "PLUGIN_ETC"
|
||||
? filePath.split("/").slice(-2).join("/")
|
||||
: filePath.split("/").slice(-2)[0];
|
||||
return `${ICXHeader}${device}/${category}/${name}.md` as FilePathWithPrefix;
|
||||
}
|
||||
|
||||
export function createCustomisationSyncV2DocumentPath(
|
||||
filePath: string,
|
||||
device: string,
|
||||
options: CustomisationSyncPathOptions
|
||||
): FilePathWithPrefix {
|
||||
const category = getCustomisationSyncFileCategory(filePath, options);
|
||||
const name =
|
||||
category == "CONFIG" || category == "SNIPPET"
|
||||
? filePath.split("/").slice(-1)[0]
|
||||
: filePath.split("/").slice(-2)[0];
|
||||
const baseName = category == "CONFIG" || category == "SNIPPET" ? name : filePath.split("/").slice(3).join("/");
|
||||
return `${ICXHeader}${device}/${category}/${name}%${baseName}` as FilePathWithPrefix;
|
||||
}
|
||||
|
||||
export function createCustomisationSyncDevicePrefix(device: string): FilePathWithPrefix {
|
||||
return `${ICXHeader}${device}/` as FilePathWithPrefix;
|
||||
}
|
||||
|
||||
export function parseCustomisationSyncV2DocumentPath(unifiedPath: FilePathWithPrefix): {
|
||||
category: string;
|
||||
device: string;
|
||||
key: string;
|
||||
filename: string;
|
||||
pathV1: FilePathWithPrefix;
|
||||
} {
|
||||
const [device, category, ...rest] = stripAllPrefixes(unifiedPath).split("/");
|
||||
const relativePath = rest.join("/");
|
||||
const [key, filename] = relativePath.split("%");
|
||||
const pathV1 = (unifiedPath.split("%")[0] + ".md") as FilePathWithPrefix;
|
||||
return { device, category, key, filename, pathV1 };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import {
|
||||
createCustomisationSyncDevicePrefix,
|
||||
createCustomisationSyncV1DocumentPath,
|
||||
createCustomisationSyncV2DocumentPath,
|
||||
getCustomisationSyncFileCategory,
|
||||
isCustomisationSyncTargetPath,
|
||||
getCustomisationSyncSettingKey,
|
||||
getCustomisationSyncSettingKeyFromDocumentPath,
|
||||
parseCustomisationSyncV2DocumentPath,
|
||||
type CustomisationSyncPathOptions,
|
||||
} from "./customisationSyncPaths.ts";
|
||||
|
||||
const currentOptions: CustomisationSyncPathOptions = {
|
||||
configDir: ".obsidian",
|
||||
useV2: true,
|
||||
usePluginEtc: true,
|
||||
};
|
||||
|
||||
describe("compatibility: Customisation Sync path operations", () => {
|
||||
it.each([
|
||||
[".obsidian/app.json", "CONFIG"],
|
||||
[".obsidian/themes/Minimal/theme.css", "THEME"],
|
||||
[".obsidian/snippets/example.css", "SNIPPET"],
|
||||
[".obsidian/plugins/example/manifest.json", "PLUGIN_MAIN"],
|
||||
[".obsidian/plugins/example/data.json", "PLUGIN_DATA"],
|
||||
[".obsidian/plugins/example/extra.json", "PLUGIN_ETC"],
|
||||
] as const)("classifies the maintained path %s as %s", (path, expected) => {
|
||||
expect(getCustomisationSyncFileCategory(path, currentOptions)).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves the exact depth and case-sensitive category rules", () => {
|
||||
expect(getCustomisationSyncFileCategory(".obsidian/themes/Minimal/assets/theme.css", currentOptions)).toBe("");
|
||||
expect(getCustomisationSyncFileCategory(".obsidian/snippets/example.CSS", currentOptions)).toBe("");
|
||||
expect(getCustomisationSyncFileCategory(".Obsidian/plugins/example/main.js", currentOptions)).toBe("");
|
||||
});
|
||||
|
||||
it("requires both V2 and plug-in-extra support for other plug-in files", () => {
|
||||
const path = ".obsidian/plugins/example/extra.json";
|
||||
|
||||
expect(getCustomisationSyncFileCategory(path, { ...currentOptions, useV2: false })).toBe("");
|
||||
expect(getCustomisationSyncFileCategory(path, { ...currentOptions, usePluginEtc: false })).toBe("");
|
||||
});
|
||||
|
||||
it("keeps category classification separate from configuration-directory targeting", () => {
|
||||
expect(getCustomisationSyncFileCategory("notes/example.json", currentOptions)).toBe("CONFIG");
|
||||
expect(isCustomisationSyncTargetPath("notes/example.json", currentOptions)).toBe(false);
|
||||
expect(isCustomisationSyncTargetPath(".obsidian/app.json", currentOptions)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".obsidian/app.json", "CONFIG/app.json"],
|
||||
[".obsidian/themes/Minimal/theme.css", "THEME/Minimal"],
|
||||
[".obsidian/snippets/example.css", "SNIPPET/example.css"],
|
||||
[".obsidian/plugins/example/main.js", "PLUGIN_MAIN/example"],
|
||||
[".obsidian/plugins/example/data.json", "PLUGIN_DATA/example"],
|
||||
[".obsidian/plugins/example/extra.json", "PLUGIN_ETC/example"],
|
||||
] as const)("maps the local path %s to setting key %s", (path, expected) => {
|
||||
expect(getCustomisationSyncSettingKey(path, currentOptions)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ix:device-a/CONFIG/app.json.md", "CONFIG/app.json"],
|
||||
["ix:device-a/CONFIG/app.json%app.json", "CONFIG/app.json"],
|
||||
["ix:device-a/THEME/Minimal.md", "THEME/Minimal"],
|
||||
["ix:device-a/PLUGIN_DATA/example%data.json", "PLUGIN_DATA/example"],
|
||||
["ix:device-a/PLUGIN_ETC/example/extra.json.md", "PLUGIN_ETC/example"],
|
||||
] as const)("maps the persisted path %s to setting key %s", (path, expected) => {
|
||||
expect(getCustomisationSyncSettingKeyFromDocumentPath(path as FilePathWithPrefix)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".obsidian/app.json", "ix:device-a/CONFIG/app.json.md"],
|
||||
[".obsidian/themes/Minimal/theme.css", "ix:device-a/THEME/Minimal.md"],
|
||||
[".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example.md"],
|
||||
[".obsidian/plugins/example/extra.json", "ix:device-a/PLUGIN_ETC/example/extra.json.md"],
|
||||
] as const)("creates the persisted V1 path for %s", (path, expected) => {
|
||||
expect(createCustomisationSyncV1DocumentPath(path, "device-a", currentOptions)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".obsidian/app.json", "ix:device-a/CONFIG/app.json%app.json"],
|
||||
[".obsidian/themes/Minimal/theme.css", "ix:device-a/THEME/Minimal%theme.css"],
|
||||
[".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example%main.js"],
|
||||
[".obsidian/plugins/example/extra.json", "ix:device-a/PLUGIN_ETC/example%extra.json"],
|
||||
] as const)("creates the persisted V2 path for %s", (path, expected) => {
|
||||
expect(createCustomisationSyncV2DocumentPath(path, "device-a", currentOptions)).toBe(expected);
|
||||
});
|
||||
|
||||
it("creates and parses device-scoped V2 paths", () => {
|
||||
expect(createCustomisationSyncDevicePrefix("device-a")).toBe("ix:device-a/");
|
||||
expect(
|
||||
parseCustomisationSyncV2DocumentPath("ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix)
|
||||
).toEqual({
|
||||
device: "device-a",
|
||||
category: "PLUGIN_MAIN",
|
||||
key: "example",
|
||||
filename: "main.js",
|
||||
pathV1: "ix:device-a/PLUGIN_MAIN/example.md",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const uiSources = [
|
||||
["PluginDialogModal", readFileSync(new URL("./PluginDialogModal.ts", import.meta.url), "utf8")],
|
||||
["PluginPane", readFileSync(new URL("./PluginPane.svelte", import.meta.url), "utf8")],
|
||||
["PluginCombo", readFileSync(new URL("./PluginCombo.svelte", import.meta.url), "utf8")],
|
||||
[
|
||||
"PaneCustomisationSync",
|
||||
readFileSync(
|
||||
new URL("../../modules/features/SettingDialogue/PaneCustomisationSync.ts", import.meta.url),
|
||||
"utf8"
|
||||
),
|
||||
],
|
||||
[
|
||||
"PaneHatch",
|
||||
readFileSync(new URL("../../modules/features/SettingDialogue/PaneHatch.ts", import.meta.url), "utf8"),
|
||||
],
|
||||
] as const;
|
||||
const customisationSyncSource = readFileSync(new URL("./customisationSyncContext.ts", import.meta.url), "utf8");
|
||||
const settingTabSource = readFileSync(
|
||||
new URL("../../modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const settingModuleSource = readFileSync(
|
||||
new URL("../../modules/features/ModuleObsidianSettingTab.ts", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
describe("optional-file synchronisation UI dependency boundary", () => {
|
||||
it.each(uiSources)("keeps %s independent from concrete add-ons and the application core", (_name, source) => {
|
||||
expect(source).not.toMatch(/from ["'][^"']*Cmd(?:Config|HiddenFile)Sync(?:\.ts)?["']/);
|
||||
expect(source).not.toMatch(/from ["'][^"']*main(?:\.ts)?["']/);
|
||||
expect(source).not.toContain("getAddOn(");
|
||||
expect(source).not.toContain("getAddOn<");
|
||||
});
|
||||
|
||||
it("keeps the Customisation Sync runtime independent from its Obsidian dialogue", () => {
|
||||
expect(customisationSyncSource).not.toMatch(/from ["'][^"']*PluginDialogModal(?:\.ts)?["']/);
|
||||
});
|
||||
|
||||
it("keeps the settings presentation out of the runtime cycle and off the compatibility lookup", () => {
|
||||
expect(settingTabSource).not.toMatch(/^import(?!\s+type\b)[^;]*from ["'][^"']*main(?:\.ts)?["'];/mu);
|
||||
expect(settingModuleSource).not.toContain("getAddOn(");
|
||||
expect(settingModuleSource).not.toContain("getAddOn<");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PluginManifest } from "@/deps.ts";
|
||||
import type {
|
||||
FilePathWithPrefix,
|
||||
LoadedEntry,
|
||||
PluginSyncSettingEntry,
|
||||
SYNC_MODE,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { Readable } from "svelte/store";
|
||||
|
||||
import type { PluginDataExFile } from "./customisationSyncCodec.ts";
|
||||
|
||||
export type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile;
|
||||
|
||||
export interface IPluginDataExDisplay {
|
||||
documentPath: FilePathWithPrefix;
|
||||
category: string;
|
||||
name: string;
|
||||
term: string;
|
||||
displayName?: string;
|
||||
files: (LoadedEntryPluginDataExFile | PluginDataExFile)[];
|
||||
version?: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export type PluginDataExDisplay = {
|
||||
documentPath: FilePathWithPrefix;
|
||||
category: string;
|
||||
name: string;
|
||||
term: string;
|
||||
displayName?: string;
|
||||
files: PluginDataExFile[];
|
||||
version?: string;
|
||||
mtime: number;
|
||||
};
|
||||
|
||||
/** Stable catalogue and operation surface consumed by the Obsidian dialogue. */
|
||||
export interface CustomisationSyncDialogView {
|
||||
readonly catalogue: Readable<IPluginDataExDisplay[]>;
|
||||
readonly enumerationActive: Readable<boolean>;
|
||||
readonly migrationProgress: Readable<number>;
|
||||
readonly manifests: Readable<Map<string, PluginManifest>>;
|
||||
|
||||
isEnabled(): boolean;
|
||||
getDeviceAndVaultName(): string;
|
||||
getConfiguredModes(): PluginSyncSettingEntry[];
|
||||
isPluginEtcEnabled(): boolean;
|
||||
updateConfiguredMode(key: string, mode: SYNC_MODE, files: string[]): void;
|
||||
getConfiguredTargetFiles(key: string): string[];
|
||||
|
||||
updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void>;
|
||||
reloadPluginList(showMessage: boolean): Promise<void>;
|
||||
scanAllConfigFiles(showMessage: boolean): Promise<void>;
|
||||
synchronise(): Promise<void>;
|
||||
applyData(data: IPluginDataExDisplay): Promise<boolean>;
|
||||
compareUsingDisplayData(
|
||||
dataA: IPluginDataExDisplay,
|
||||
dataB: IPluginDataExDisplay,
|
||||
compareEach?: boolean
|
||||
): Promise<boolean>;
|
||||
compareFileUsingDisplayData(
|
||||
dataA: IPluginDataExDisplay,
|
||||
dataB: IPluginDataExDisplay,
|
||||
filename: string
|
||||
): Promise<boolean>;
|
||||
deleteData(data: IPluginDataExDisplay): Promise<boolean>;
|
||||
duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise<void>;
|
||||
askString(title: string, key: string, placeholder: string): Promise<string | false>;
|
||||
}
|
||||
|
||||
/** Narrow control returned by the host-owned dialogue composition feature. */
|
||||
export interface CustomisationSyncUIControl {
|
||||
open(): void;
|
||||
close(): void;
|
||||
isOpen(): boolean;
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type DocumentID,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
type UXFileInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||
LiveSyncCommands: class LiveSyncCommands {
|
||||
plugin!: { app: unknown };
|
||||
core!: { services: unknown; settings: unknown };
|
||||
get app() {
|
||||
return this.plugin.app;
|
||||
}
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
get settings() {
|
||||
return this.core.settings;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
configureHiddenFileSyncMode: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
|
||||
|
||||
function createHiddenRevisionOperation() {
|
||||
const path = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
const file = {
|
||||
path,
|
||||
name: "data.json",
|
||||
isInternal: true,
|
||||
body: new Blob(["{\"value\":\"vault\"}"]),
|
||||
stat: {
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 17,
|
||||
type: "file",
|
||||
},
|
||||
} as UXFileInfo;
|
||||
const selected = {
|
||||
_id: "i:example" as DocumentID,
|
||||
_rev: "2-selected",
|
||||
path: `i:${path}` as FilePathWithPrefix,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 17,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
deleted: false,
|
||||
} as MetaEntry;
|
||||
const winner = {
|
||||
...selected,
|
||||
_rev: "3-winner",
|
||||
} as MetaEntry;
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(
|
||||
async (_path: unknown, revision?: string) =>
|
||||
revision === selected._rev ? selected : winner
|
||||
),
|
||||
getConflictedRevs: vi.fn(async () => [selected._rev]),
|
||||
fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: "{\"value\":\"database\"}" })),
|
||||
storeWithBaseRevision: vi.fn(async () => "3-vault-child"),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
services: {
|
||||
vault: {
|
||||
isIgnoredByIgnoreFile: vi.fn(async () => false),
|
||||
},
|
||||
},
|
||||
databaseFileAccess,
|
||||
},
|
||||
loadFileWithInfo: vi.fn(async () => file),
|
||||
updateLastProcessed: vi.fn(),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
file,
|
||||
selected,
|
||||
winner,
|
||||
databaseFileAccess,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
}> = [];
|
||||
const settings = {
|
||||
syncInternalFiles: false,
|
||||
useAdvancedMode: false,
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings,
|
||||
services: {
|
||||
API: {
|
||||
addCommand: vi.fn((command) => commands.push(command)),
|
||||
},
|
||||
},
|
||||
},
|
||||
_isMainReady: vi.fn(() => true),
|
||||
_isMainSuspended: vi.fn(() => false),
|
||||
_isDatabaseReady: vi.fn(() => true),
|
||||
});
|
||||
|
||||
hiddenFileSync.onload();
|
||||
|
||||
const commandIds = [
|
||||
"livesync-sync-internal",
|
||||
"livesync-scaninternal-storage",
|
||||
"livesync-scaninternal-database",
|
||||
"livesync-internal-scan-offline-changes",
|
||||
];
|
||||
for (const commandId of commandIds) {
|
||||
const command = commands.find(({ id }) => id === commandId);
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
}
|
||||
|
||||
settings.syncInternalFiles = true;
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
const command = commands.find(({ id }) => id === commandId);
|
||||
expect(command?.checkCallback?.(true)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not report Hidden File Sync as ready before the main runtime is ready", () => {
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: {
|
||||
syncInternalFiles: true,
|
||||
},
|
||||
},
|
||||
_isMainReady: vi.fn(() => false),
|
||||
_isMainSuspended: vi.fn(() => false),
|
||||
});
|
||||
|
||||
expect(hiddenFileSync.isReady()).toBe(false);
|
||||
});
|
||||
|
||||
it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => {
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(() => true),
|
||||
removeItem: vi.fn(() => true),
|
||||
};
|
||||
const plugin = {
|
||||
app: {
|
||||
plugins: {
|
||||
manifests: {
|
||||
alpha: {
|
||||
id: "alpha",
|
||||
name: "Alpha",
|
||||
dir: ".obsidian/plugins/alpha",
|
||||
},
|
||||
beta: {
|
||||
id: "beta",
|
||||
name: "Beta",
|
||||
dir: ".obsidian/plugins/beta",
|
||||
},
|
||||
},
|
||||
enabledPlugins: new Set(["alpha", "beta"]),
|
||||
unloadPlugin: vi.fn(async () => undefined),
|
||||
loadPlugin: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
confirm: { askInPopup: vi.fn() },
|
||||
services: {
|
||||
context: { noticeGroups },
|
||||
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
|
||||
appLifecycle: {
|
||||
isReloadingScheduled: vi.fn(() => false),
|
||||
scheduleRestart: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
plugin,
|
||||
core,
|
||||
queuedNotificationFiles: new Set([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
hiddenFileSync.notifyConfigChange();
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "hidden-file-changes", "plugin:alpha", {
|
||||
message: "Files in Alpha were updated.",
|
||||
action: expect.objectContaining({ label: "Reload Alpha" }),
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "hidden-file-changes", "plugin:beta", {
|
||||
message: "Files in Beta were updated.",
|
||||
action: expect.objectContaining({ label: "Reload Beta" }),
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(3, "hidden-file-changes", "restart", {
|
||||
message: "Other Obsidian settings files were updated.",
|
||||
action: expect.objectContaining({ label: "Schedule an Obsidian restart" }),
|
||||
});
|
||||
expect(noticeGroups.setItem.mock.calls.every(([groupKey]) => groupKey === "hidden-file-changes")).toBe(true);
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("hidden-file-changes", { durationMs: 20_000 });
|
||||
expect(core.confirm.askInPopup).not.toHaveBeenCalled();
|
||||
|
||||
const reloadAction = (noticeGroups.setItem.mock.calls[0]?.[2] as { action: { onSelect: () => void } }).action
|
||||
.onSelect;
|
||||
reloadAction();
|
||||
await vi.waitFor(() => {
|
||||
expect(plugin.app.plugins.unloadPlugin).toHaveBeenCalledWith("alpha");
|
||||
expect(plugin.app.plugins.loadPlugin).toHaveBeenCalledWith("alpha");
|
||||
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "plugin:alpha");
|
||||
});
|
||||
|
||||
const restartAction = (noticeGroups.setItem.mock.calls[2]?.[2] as { action: { onSelect: () => void } }).action
|
||||
.onSelect;
|
||||
restartAction();
|
||||
expect(core.services.appLifecycle.scheduleRestart).toHaveBeenCalledOnce();
|
||||
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart");
|
||||
});
|
||||
|
||||
it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => {
|
||||
const progress = {
|
||||
log: vi.fn(),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const rebuildMerging = vi.fn(async () => []);
|
||||
const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined);
|
||||
const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined);
|
||||
const scanAllStorageChanges = vi.fn(async () => undefined);
|
||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
_progress: vi.fn(() => progress),
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed,
|
||||
adoptCurrentDatabaseFilesAsProcessed,
|
||||
scanAllStorageChanges,
|
||||
scanAllDatabaseChanges,
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
|
||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
await handlers.initialise("safe");
|
||||
return "enabled";
|
||||
});
|
||||
const events: string[] = [];
|
||||
const progress = {
|
||||
log: vi.fn((message: string) => {
|
||||
events.push(`progress:${message}`);
|
||||
}),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const createProgress = vi.fn(() => progress);
|
||||
const applyPartial = vi.fn(async () => {
|
||||
events.push("apply-settings");
|
||||
});
|
||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
services: {
|
||||
setting: { applyPartial },
|
||||
},
|
||||
},
|
||||
initialiseInternalFileSync,
|
||||
_progress: createProgress,
|
||||
_log: log,
|
||||
});
|
||||
|
||||
await hiddenFileSync.configureHiddenFileSync("MERGE");
|
||||
|
||||
expect(createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
|
||||
expect(events[0]).toBe("progress:Preparing Hidden File Sync...");
|
||||
expect(initialiseInternalFileSync).toHaveBeenCalledWith("safe", true, false, progress);
|
||||
expect(log).not.toHaveBeenCalledWith("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
|
||||
expect(log).not.toHaveBeenCalledWith("Done! Restarting the app is strongly recommended!", LOG_LEVEL_NOTICE);
|
||||
expect(log).toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
|
||||
});
|
||||
|
||||
it("closes the preparation Notice when enabling Hidden File Sync fails", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
return "enabled";
|
||||
});
|
||||
const error = new Error("setting persistence failed");
|
||||
const progress = {
|
||||
log: vi.fn(),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
services: {
|
||||
setting: {
|
||||
applyPartial: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(hiddenFileSync.configureHiddenFileSync("MERGE")).rejects.toBe(error);
|
||||
|
||||
expect(progress.done).toHaveBeenCalledWith("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiddenFileSync exact revision repair operations", () => {
|
||||
it("stores the current hidden Vault file as a child of the selected live revision", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: file.path,
|
||||
body: file.body,
|
||||
isInternal: true,
|
||||
}),
|
||||
selected._rev,
|
||||
true
|
||||
);
|
||||
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
|
||||
file.path,
|
||||
expect.objectContaining({ _rev: "3-vault-child" }),
|
||||
file.stat
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to extend a hidden-file revision which is no longer live", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
|
||||
file,
|
||||
selected._rev!,
|
||||
false
|
||||
)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a matching hidden-file revision without creating a child", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({
|
||||
...selected,
|
||||
data: "{\"value\":\"vault\"}",
|
||||
});
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
|
||||
file,
|
||||
selected._rev!,
|
||||
false
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
|
||||
file.path,
|
||||
selected,
|
||||
file.stat
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the selected live hidden-file revision through the existing extraction path", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
selected,
|
||||
} = createHiddenRevisionOperation();
|
||||
const extract = vi.fn(async () => true);
|
||||
hiddenFileSync.extractInternalFileFromDatabase = extract;
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev);
|
||||
});
|
||||
|
||||
it("does not apply a hidden-file revision which ceased to be live", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
configureHiddenFileSyncMode: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HiddenFileSyncContext } from "./hiddenFileSyncContext.ts";
|
||||
|
||||
const PATH = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
|
||||
function createHiddenFileSync(
|
||||
options: { owned?: boolean; ignoredByIgnoreFile?: boolean; patternMatch?: boolean } = {}
|
||||
) {
|
||||
const ownsLocalFile = vi.fn(() => options.owned ?? true);
|
||||
const isIgnoredByIgnoreFile = vi.fn(async () => options.ignoredByIgnoreFile ?? false);
|
||||
const isTargetFileInPatterns = vi.fn(() => options.patternMatch ?? true);
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: { ownsLocalFile, isIgnoredByIgnoreFile },
|
||||
isTargetFileInPatterns,
|
||||
});
|
||||
return { hiddenFileSync, isIgnoredByIgnoreFile, isTargetFileInPatterns, ownsLocalFile };
|
||||
}
|
||||
|
||||
describe("Hidden File Sync local-path admission", () => {
|
||||
it("checks composition ownership before Hidden File Sync filters", async () => {
|
||||
const { hiddenFileSync, isTargetFileInPatterns, ownsLocalFile } = createHiddenFileSync({ owned: false });
|
||||
|
||||
await expect(hiddenFileSync.isTargetFile(PATH)).resolves.toBe(false);
|
||||
expect(ownsLocalFile).toHaveBeenCalledWith(PATH);
|
||||
expect(isTargetFileInPatterns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps target patterns and ignore-file results as Hidden File Sync eligibility", async () => {
|
||||
const patternExcluded = createHiddenFileSync({ patternMatch: false });
|
||||
await expect(patternExcluded.hiddenFileSync.isTargetFile(PATH)).resolves.toBe(false);
|
||||
expect(patternExcluded.isIgnoredByIgnoreFile).not.toHaveBeenCalled();
|
||||
|
||||
const ignoreFileExcluded = createHiddenFileSync({ ignoredByIgnoreFile: true });
|
||||
await expect(ignoreFileExcluded.hiddenFileSync.isTargetFile(PATH)).resolves.toBe(false);
|
||||
expect(ignoreFileExcluded.isIgnoredByIgnoreFile).toHaveBeenCalledWith(PATH);
|
||||
|
||||
const admitted = createHiddenFileSync();
|
||||
await expect(admitted.hiddenFileSync.isTargetFile(PATH)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("exposes eligibility without consulting the composition owner", async () => {
|
||||
const { hiddenFileSync, ownsLocalFile } = createHiddenFileSync({ owned: false });
|
||||
|
||||
await expect(hiddenFileSync.isTargetFileEligible(PATH)).resolves.toBe(true);
|
||||
expect(ownsLocalFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compatibility: Hidden File Sync path shape", () => {
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
|
||||
it.each([
|
||||
[".obsidian/app.json", true],
|
||||
[".trash/app.json", false],
|
||||
["notes/app.json", false],
|
||||
])("recognises %s as a Hidden File Sync path=%s", (path, expected) => {
|
||||
expect(hiddenFileSync.isHiddenFileSyncHandlingPath(path as FilePath)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
configureHiddenFileSyncMode: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HiddenFileSyncContext } from "./hiddenFileSyncContext.ts";
|
||||
|
||||
function createContext() {
|
||||
const periodicProcessor = { enable: vi.fn(), disable: vi.fn() };
|
||||
const publishActivity = vi.fn();
|
||||
const hideConfigurationChangeNotice = vi.fn();
|
||||
const context = new HiddenFileSyncContext({
|
||||
createPeriodicProcessor: vi.fn(() => periodicProcessor),
|
||||
publishActivity,
|
||||
closeJsonConflictDialogs: vi.fn(),
|
||||
hideConfigurationChangeNotice,
|
||||
} as never);
|
||||
return { context, hideConfigurationChangeNotice, periodicProcessor, publishActivity };
|
||||
}
|
||||
|
||||
describe("HiddenFileSyncContext state ownership", () => {
|
||||
it("owns queues, caches, concurrency controls, and processors per context instance", () => {
|
||||
const first = createContext();
|
||||
const second = createContext();
|
||||
|
||||
first.context.pendingConflictChecks.add("i:.obsidian/first.json" as never);
|
||||
first.context.queuedNotificationFiles.add(".obsidian/plugins/first");
|
||||
first.context.cacheFileRegExps.set("first", []);
|
||||
|
||||
expect(second.context.pendingConflictChecks).toEqual(new Set());
|
||||
expect(second.context.queuedNotificationFiles).toEqual(new Set());
|
||||
expect(second.context.cacheFileRegExps).toEqual(new Map());
|
||||
expect(first.context.conflictResolutionProcessor).not.toBe(second.context.conflictResolutionProcessor);
|
||||
expect(first.context.semaphore).not.toBe(second.context.semaphore);
|
||||
expect(first.context.periodicInternalFileScanProcessor).toBe(first.periodicProcessor);
|
||||
expect(second.context.periodicInternalFileScanProcessor).toBe(second.periodicProcessor);
|
||||
|
||||
first.context.dispose();
|
||||
second.context.dispose();
|
||||
});
|
||||
|
||||
it("publishes instance-owned event and processing counts at each transition", async () => {
|
||||
const { context, publishActivity } = createContext();
|
||||
const path = ".obsidian/app.json" as FilePath;
|
||||
|
||||
await context.serializedForEvent(path, async () => {
|
||||
expect(publishActivity).toHaveBeenLastCalledWith(1, 1);
|
||||
});
|
||||
|
||||
expect(publishActivity.mock.calls).toEqual([
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
[0, 0],
|
||||
]);
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new Map(), true],
|
||||
[new Map([[".obsidian/app.json", "1-2-3"]]), false],
|
||||
])(
|
||||
"preserves start-up scan notice selection for the processed-file cache",
|
||||
async (processedFiles, forcedNotice) => {
|
||||
const { context } = createContext();
|
||||
const performStartupScan = vi.fn(async () => undefined);
|
||||
const keyValueDatabase = {
|
||||
get: vi.fn(async (key: IDBValidKey) => {
|
||||
if (key == "hidden-file-lastProcessed") return processedFiles;
|
||||
return new Map();
|
||||
}),
|
||||
};
|
||||
Object.assign(context, {
|
||||
dependencies: {
|
||||
createPeriodicProcessor: vi.fn(),
|
||||
getKeyValueDatabase: () => keyValueDatabase,
|
||||
getSettings: () => ({ syncInternalFiles: true }),
|
||||
log: vi.fn(),
|
||||
},
|
||||
performStartupScan,
|
||||
});
|
||||
|
||||
await context._everyOnDatabaseInitialized(false);
|
||||
|
||||
expect(performStartupScan).toHaveBeenCalledWith(forcedNotice);
|
||||
context.conflictResolutionProcessor.terminate();
|
||||
}
|
||||
);
|
||||
});
|
||||
+295
-369
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,350 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type DocumentID,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
type UXFileInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
configureHiddenFileSyncMode: vi.fn(),
|
||||
}));
|
||||
|
||||
import { HiddenFileSyncContext } from "./hiddenFileSyncContext.ts";
|
||||
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
|
||||
|
||||
function createHiddenRevisionOperation() {
|
||||
const path = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
const file = {
|
||||
path,
|
||||
name: "data.json",
|
||||
isInternal: true,
|
||||
body: new Blob(['{"value":"vault"}']),
|
||||
stat: {
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 17,
|
||||
type: "file",
|
||||
},
|
||||
} as UXFileInfo;
|
||||
const selected = {
|
||||
_id: "i:example" as DocumentID,
|
||||
_rev: "2-selected",
|
||||
path: `i:${path}` as FilePathWithPrefix,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 17,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
deleted: false,
|
||||
} as MetaEntry;
|
||||
const winner = {
|
||||
...selected,
|
||||
_rev: "3-winner",
|
||||
} as MetaEntry;
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(async (_path: unknown, revision?: string) =>
|
||||
revision === selected._rev ? selected : winner
|
||||
),
|
||||
getConflictedRevs: vi.fn(async () => [selected._rev]),
|
||||
fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: '{"value":"database"}' })),
|
||||
storeWithBaseRevision: vi.fn(async () => "3-vault-child"),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: {
|
||||
databaseFileAccess,
|
||||
isIgnoredByIgnoreFile: vi.fn(async () => false),
|
||||
},
|
||||
loadFileWithInfo: vi.fn(async () => file),
|
||||
updateLastProcessed: vi.fn(),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
file,
|
||||
selected,
|
||||
winner,
|
||||
databaseFileAccess,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HiddenFileSyncContext configuration-change notices", () => {
|
||||
it("releases processors, transient queues, the pattern cache, activity, and the host Notice effect", () => {
|
||||
const periodicInternalFileScanProcessor = { disable: vi.fn() };
|
||||
const conflictResolutionProcessor = { terminate: vi.fn() };
|
||||
const pendingConflictChecks = new Set(["i:.obsidian/example.json"]);
|
||||
const queuedNotificationFiles = new Set([".obsidian/plugins/example"]);
|
||||
const cacheFileRegExps = new Map([["patterns", []]]);
|
||||
const publishActivity = vi.fn();
|
||||
const hideConfigurationChangeNotice = vi.fn();
|
||||
const closeJsonConflictDialogs = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: { publishActivity, hideConfigurationChangeNotice, closeJsonConflictDialogs },
|
||||
periodicInternalFileScanProcessor,
|
||||
conflictResolutionProcessor,
|
||||
pendingConflictChecks,
|
||||
queuedNotificationFiles,
|
||||
cacheFileRegExps,
|
||||
eventCount: 4,
|
||||
processingCount: 2,
|
||||
});
|
||||
|
||||
hiddenFileSync.dispose();
|
||||
hiddenFileSync.dispose();
|
||||
|
||||
expect(periodicInternalFileScanProcessor.disable).toHaveBeenCalledOnce();
|
||||
expect(conflictResolutionProcessor.terminate).toHaveBeenCalledOnce();
|
||||
expect(pendingConflictChecks.size).toBe(0);
|
||||
expect(queuedNotificationFiles.size).toBe(0);
|
||||
expect(cacheFileRegExps.size).toBe(0);
|
||||
expect(publishActivity).toHaveBeenCalledWith(0, 0);
|
||||
expect(closeJsonConflictDialogs).toHaveBeenCalledOnce();
|
||||
expect(hideConfigurationChangeNotice).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not report Hidden File Sync as ready before the main runtime is ready", () => {
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: {
|
||||
getSettings: () => ({
|
||||
syncInternalFiles: true,
|
||||
}),
|
||||
},
|
||||
_isMainReady: vi.fn(() => false),
|
||||
_isMainSuspended: vi.fn(() => false),
|
||||
});
|
||||
|
||||
expect(hiddenFileSync.isReady()).toBe(false);
|
||||
});
|
||||
|
||||
it("settles one batch of changed folders through the host notification effect", () => {
|
||||
const showConfigurationChangeNotice = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: { showConfigurationChangeNotice },
|
||||
queuedNotificationFiles: new Set([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]),
|
||||
});
|
||||
|
||||
hiddenFileSync.notifyConfigChange();
|
||||
|
||||
expect(showConfigurationChangeNotice).toHaveBeenCalledWith([
|
||||
".obsidian/plugins/alpha",
|
||||
".obsidian/plugins/beta",
|
||||
".obsidian",
|
||||
]);
|
||||
expect(hiddenFileSync.queuedNotificationFiles.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => {
|
||||
const progress = {
|
||||
log: vi.fn(),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const rebuildMerging = vi.fn(async () => []);
|
||||
const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined);
|
||||
const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined);
|
||||
const scanAllStorageChanges = vi.fn(async () => undefined);
|
||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
_progress: vi.fn(() => progress),
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed,
|
||||
adoptCurrentDatabaseFilesAsProcessed,
|
||||
scanAllStorageChanges,
|
||||
scanAllDatabaseChanges,
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
|
||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
await handlers.initialise("safe");
|
||||
return "enabled";
|
||||
});
|
||||
const events: string[] = [];
|
||||
const progress = {
|
||||
log: vi.fn((message: string) => {
|
||||
events.push(`progress:${message}`);
|
||||
}),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const createProgress = vi.fn(() => progress);
|
||||
const applyPartial = vi.fn(async () => {
|
||||
events.push("apply-settings");
|
||||
});
|
||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: { applySettings: applyPartial },
|
||||
initialiseInternalFileSync,
|
||||
_progress: createProgress,
|
||||
_log: log,
|
||||
});
|
||||
|
||||
await hiddenFileSync.configureHiddenFileSync("MERGE");
|
||||
|
||||
expect(createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
|
||||
expect(events[0]).toBe("progress:Preparing Hidden File Sync...");
|
||||
expect(initialiseInternalFileSync).toHaveBeenCalledWith("safe", true, false, progress);
|
||||
expect(log).not.toHaveBeenCalledWith("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
|
||||
expect(log).not.toHaveBeenCalledWith("Done! Restarting the app is strongly recommended!", LOG_LEVEL_NOTICE);
|
||||
expect(log).toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
|
||||
});
|
||||
|
||||
it("closes the preparation Notice when enabling Hidden File Sync fails", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
return "enabled";
|
||||
});
|
||||
const error = new Error("setting persistence failed");
|
||||
const progress = {
|
||||
log: vi.fn(),
|
||||
once: vi.fn(),
|
||||
done: vi.fn(),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: {
|
||||
applySettings: vi.fn(async () => {
|
||||
throw error;
|
||||
}),
|
||||
},
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(hiddenFileSync.configureHiddenFileSync("MERGE")).rejects.toBe(error);
|
||||
|
||||
expect(progress.done).toHaveBeenCalledWith("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiddenFileSyncContext reconciliation settlement", () => {
|
||||
it("compatibility: consumes a selected database file even when reading its Metadata fails", async () => {
|
||||
const error = new Error("metadata unavailable");
|
||||
const getDBEntryMeta = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
|
||||
Object.assign(hiddenFileSync, {
|
||||
dependencies: {
|
||||
getLocalDatabase: () => ({ getDBEntryMeta }),
|
||||
log,
|
||||
},
|
||||
serializedForEvent: vi.fn(async (_path: FilePath, operation: () => Promise<boolean>) => await operation()),
|
||||
});
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.trackDatabaseFileModification(".obsidian/app.json" as FilePath, "[Replication]")
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(log).toHaveBeenCalledWith("[Replication] Failed to process hidden file", undefined, undefined);
|
||||
expect(log).toHaveBeenCalledWith(error, expect.any(Number), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiddenFileSyncContext exact revision repair operations", () => {
|
||||
it("stores the current hidden Vault file as a child of the selected live revision", async () => {
|
||||
const { hiddenFileSync, file, selected, databaseFileAccess } = createHiddenRevisionOperation();
|
||||
|
||||
await expect(hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: file.path,
|
||||
body: file.body,
|
||||
isInternal: true,
|
||||
}),
|
||||
selected._rev,
|
||||
true
|
||||
);
|
||||
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
|
||||
file.path,
|
||||
expect.objectContaining({ _rev: "3-vault-child" }),
|
||||
file.stat
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to extend a hidden-file revision which is no longer live", async () => {
|
||||
const { hiddenFileSync, file, selected, databaseFileAccess } = createHiddenRevisionOperation();
|
||||
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
|
||||
|
||||
await expect(hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => {
|
||||
const { hiddenFileSync, file, selected, databaseFileAccess } = createHiddenRevisionOperation();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!, false)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a matching hidden-file revision without creating a child", async () => {
|
||||
const { hiddenFileSync, file, selected, databaseFileAccess } = createHiddenRevisionOperation();
|
||||
databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({
|
||||
...selected,
|
||||
data: '{"value":"vault"}',
|
||||
});
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!, false)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(file.path, selected, file.stat);
|
||||
});
|
||||
|
||||
it("applies the selected live hidden-file revision through the existing extraction path", async () => {
|
||||
const { hiddenFileSync, path, selected } = createHiddenRevisionOperation();
|
||||
const extract = vi.fn(async () => true);
|
||||
hiddenFileSync.extractInternalFileFromDatabase = extract;
|
||||
|
||||
await expect(hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev);
|
||||
});
|
||||
|
||||
it("does not apply a hidden-file revision which ceased to be live", async () => {
|
||||
const { hiddenFileSync, path, selected, databaseFileAccess } = createHiddenRevisionOperation();
|
||||
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
|
||||
|
||||
await expect(hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)).resolves.toBe(
|
||||
false
|
||||
);
|
||||
|
||||
expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { InternalFileInfo } from "@/common/types.ts";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export type HiddenFileSyncInitialisationDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
|
||||
|
||||
/** Initialisation operation needed by the Customisation Sync dialogue. */
|
||||
export interface HiddenFileSyncInitialisationView {
|
||||
initialiseInternalFileSync(
|
||||
direction: HiddenFileSyncInitialisationDirection,
|
||||
showMessage: boolean,
|
||||
targetFiles?: string[] | false
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/** Exact-revision operations needed by the Hatch repair pane. */
|
||||
export interface HiddenFileSyncRepairView {
|
||||
scanInternalFiles(): Promise<InternalFileInfo[]>;
|
||||
storeInternalFileToDatabase(file: InternalFileInfo, forceWrite?: boolean): Promise<boolean | undefined>;
|
||||
storeInternalFileToDatabaseWithBaseRevision(
|
||||
file: InternalFileInfo,
|
||||
baseRevision: string,
|
||||
createIfDifferent?: boolean
|
||||
): Promise<boolean>;
|
||||
extractInternalFileRevisionFromDatabase(
|
||||
storageFilePath: FilePath,
|
||||
revision: string,
|
||||
force?: boolean
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Operations consumed by the host-owned Hidden File Sync commands. */
|
||||
export interface HiddenFileSyncCommandView extends HiddenFileSyncInitialisationView {
|
||||
isManualCommandAvailable(): boolean;
|
||||
scanAllStorageChanges(showNotice: boolean): Promise<unknown>;
|
||||
scanAllDatabaseChanges(showNotice: boolean): Promise<unknown>;
|
||||
applyOfflineChanges(showNotice: boolean): Promise<unknown>;
|
||||
updateSettingCache(): void;
|
||||
}
|
||||
@@ -1,93 +1,16 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type AnyEntry,
|
||||
type DocumentID,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { MARK_DONE } from "@/modules/features/ModuleLog.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
// import { __$checkInstanceBinding } from "@vrtmrz/livesync-commonlib/compat/dev/checks";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
|
||||
let noticeIndex = 0;
|
||||
export abstract class LiveSyncCommands {
|
||||
core: LiveSyncCore;
|
||||
get app() {
|
||||
return this.services.context.app;
|
||||
}
|
||||
get settings() {
|
||||
return this.core.settings;
|
||||
}
|
||||
get localDatabase() {
|
||||
return this.core.localDatabase;
|
||||
}
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
|
||||
async path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise<DocumentID> {
|
||||
return await this.services.path.path2id(filename, prefix);
|
||||
}
|
||||
|
||||
getPath(entry: AnyEntry): FilePathWithPrefix {
|
||||
return this.services.path.getPath(entry);
|
||||
}
|
||||
import { LiveSyncContext } from "./LiveSyncContext.ts";
|
||||
|
||||
export abstract class LiveSyncCommands extends LiveSyncContext {
|
||||
constructor(core: LiveSyncCore) {
|
||||
this.core = core;
|
||||
super(core);
|
||||
this.onBindFunction(this.core, this.core.services);
|
||||
this._log = createInstanceLogFunction(this.constructor.name, this.services.API);
|
||||
// __$checkInstanceBinding(this);
|
||||
}
|
||||
abstract onunload(): void;
|
||||
abstract onload(): void | Promise<void>;
|
||||
|
||||
_isMainReady() {
|
||||
return this.services.appLifecycle.isReady();
|
||||
}
|
||||
_isMainSuspended() {
|
||||
return this.services.appLifecycle.isSuspended();
|
||||
}
|
||||
_isDatabaseReady() {
|
||||
return this.services.database.isDatabaseReady();
|
||||
}
|
||||
|
||||
_log: ReturnType<typeof createInstanceLogFunction>;
|
||||
|
||||
_verbose = (msg: unknown, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_VERBOSE, key);
|
||||
};
|
||||
|
||||
_info = (msg: unknown, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_INFO, key);
|
||||
};
|
||||
|
||||
_notice = (msg: unknown, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_NOTICE, key);
|
||||
};
|
||||
_progress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) => {
|
||||
const key = `keepalive-progress-${noticeIndex++}`;
|
||||
return {
|
||||
log: (msg: string) => {
|
||||
this._log(prefix + msg, level, key);
|
||||
},
|
||||
once: (msg: string) => {
|
||||
this._log(prefix + msg, level);
|
||||
},
|
||||
done: (msg: string = "Done") => {
|
||||
this._log(prefix + msg + MARK_DONE, level, key);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
_debug = (msg: unknown, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_VERBOSE, key);
|
||||
};
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services) {
|
||||
// Override if needed.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type AnyEntry,
|
||||
type DocumentID,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
|
||||
import { MARK_DONE } from "@/modules/features/ModuleLog.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
|
||||
let noticeIndex = 0;
|
||||
|
||||
/** Shared host access and logging helpers for stateful feature contexts. */
|
||||
export abstract class LiveSyncContext {
|
||||
readonly core: LiveSyncCore;
|
||||
|
||||
constructor(core: LiveSyncCore) {
|
||||
this.core = core;
|
||||
this._log = createInstanceLogFunction(this.constructor.name, this.services.API);
|
||||
}
|
||||
|
||||
get app() {
|
||||
return this.services.context.app;
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.core.settings;
|
||||
}
|
||||
|
||||
get localDatabase() {
|
||||
return this.core.localDatabase;
|
||||
}
|
||||
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
|
||||
async path2id(filename: FilePathWithPrefix | FilePath, prefix?: string): Promise<DocumentID> {
|
||||
return await this.services.path.path2id(filename, prefix);
|
||||
}
|
||||
|
||||
getPath(entry: AnyEntry): FilePathWithPrefix {
|
||||
return this.services.path.getPath(entry);
|
||||
}
|
||||
|
||||
_isMainReady() {
|
||||
return this.services.appLifecycle.isReady();
|
||||
}
|
||||
|
||||
_isMainSuspended() {
|
||||
return this.services.appLifecycle.isSuspended();
|
||||
}
|
||||
|
||||
_isDatabaseReady() {
|
||||
return this.services.database.isDatabaseReady();
|
||||
}
|
||||
|
||||
_log: ReturnType<typeof createInstanceLogFunction>;
|
||||
|
||||
_verbose = (msg: unknown, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_VERBOSE, key);
|
||||
};
|
||||
|
||||
_info = (msg: unknown, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_INFO, key);
|
||||
};
|
||||
|
||||
_notice = (msg: unknown, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_NOTICE, key);
|
||||
};
|
||||
|
||||
_progress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) => {
|
||||
const key = `keepalive-progress-${noticeIndex++}`;
|
||||
return {
|
||||
log: (msg: string) => {
|
||||
this._log(prefix + msg, level, key);
|
||||
},
|
||||
once: (msg: string) => {
|
||||
this._log(prefix + msg, level);
|
||||
},
|
||||
done: (msg: string = "Done") => {
|
||||
this._log(prefix + msg + MARK_DONE, level, key);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
_debug = (msg: unknown, key?: string) => {
|
||||
this._log(msg, LOG_LEVEL_VERBOSE, key);
|
||||
};
|
||||
}
|
||||
+21
-5
@@ -2,8 +2,6 @@ import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./de
|
||||
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
setGetLanguage(getLanguage);
|
||||
import { LiveSyncCommands } from "./features/LiveSyncCommands.ts";
|
||||
import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./modules/features/ModuleInteractiveConflictResolver.ts";
|
||||
@@ -46,9 +44,13 @@ import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync
|
||||
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
|
||||
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
|
||||
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
|
||||
import { useCustomisationSyncUI } from "./serviceFeatures/useCustomisationSyncUI.ts";
|
||||
import { useOptionalFileSync, type OptionalFileSyncFeature } from "./serviceFeatures/useOptionalFileSync.ts";
|
||||
import { useHiddenFileSyncCommands } from "./serviceFeatures/useHiddenFileSyncCommands.ts";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core: LiveSyncCore;
|
||||
optionalFileSync?: OptionalFileSyncFeature;
|
||||
|
||||
/**
|
||||
* Initialise service modules.
|
||||
@@ -155,7 +157,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
(core) => {
|
||||
const extraModules = [
|
||||
new ModuleObsidianEvents(this, core),
|
||||
new ModuleObsidianSettingDialogue(this, core),
|
||||
new ModuleObsidianSettingDialogue(this, core, {
|
||||
getHiddenFileSyncRepair: () => this.optionalFileSync?.hiddenFileSyncRepair,
|
||||
}),
|
||||
new ModuleObsidianMenu(core),
|
||||
new ModuleObsidianSettingsAsMarkdown(core),
|
||||
new ModuleLog(this, core),
|
||||
@@ -169,8 +173,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
return extraModules;
|
||||
},
|
||||
(core) => {
|
||||
const addOns = [new ConfigSync(core), new HiddenFileSync(core), new LocalDatabaseMaintenance(core)];
|
||||
return addOns;
|
||||
return [new LocalDatabaseMaintenance(core)];
|
||||
},
|
||||
(core) => {
|
||||
//TODO Fix: useXXXX
|
||||
@@ -202,6 +205,19 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
);
|
||||
waitForCompatibilityReview = () => compatibilityReview.openReview();
|
||||
useReviewHarness(core, this, compatibilityReview);
|
||||
|
||||
let customisationSyncUI: ReturnType<typeof useCustomisationSyncUI> | undefined;
|
||||
const optionalFileSync = useOptionalFileSync(core, {
|
||||
getUIControl: () => customisationSyncUI,
|
||||
});
|
||||
customisationSyncUI = useCustomisationSyncUI(
|
||||
core,
|
||||
this.app,
|
||||
optionalFileSync.customisationSync,
|
||||
optionalFileSync.hiddenFileSyncInitialisation
|
||||
);
|
||||
useHiddenFileSyncCommands(core, optionalFileSync.hiddenFileSyncCommands);
|
||||
this.optionalFileSync = optionalFileSync;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager";
|
||||
import {
|
||||
StorageEventManagerBase,
|
||||
type StorageEventManagerBaseDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager";
|
||||
import { ObsidianStorageEventManagerAdapter } from "./ObsidianStorageEventManagerAdapter";
|
||||
|
||||
export class StorageEventManagerObsidian extends StorageEventManagerBase<ObsidianStorageEventManagerAdapter> {
|
||||
core: LiveSyncCore;
|
||||
|
||||
// Necessary evil.
|
||||
// cmdHiddenFileSync: HiddenFileSync;
|
||||
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, dependencies: StorageEventManagerBaseDependencies) {
|
||||
const adapter = new ObsidianStorageEventManagerAdapter(plugin, core, dependencies.fileProcessing);
|
||||
super(adapter, dependencies);
|
||||
@@ -22,6 +22,8 @@ export class StorageEventManagerObsidian extends StorageEventManagerBase<Obsidia
|
||||
protected override async _watchVaultRawEvents(path: FilePath) {
|
||||
if (!this.settings.syncInternalFiles && !this.settings.usePluginSync) return;
|
||||
if (!this.settings.watchInternalFileChanges) return;
|
||||
if (!this.settings.isConfigured || this.settings.suspendFileWatching) return;
|
||||
if (this.settings.maxMTimeForReflectEvents > 0) return;
|
||||
if (!path.startsWith(this.core.services.API.getSystemConfigDir())) return;
|
||||
if (path.endsWith("/")) {
|
||||
// Folder
|
||||
@@ -30,6 +32,15 @@ export class StorageEventManagerObsidian extends StorageEventManagerBase<Obsidia
|
||||
const isTargetFile = await this.vaultService.isTargetFileInExtra(path);
|
||||
if (!isTargetFile) return;
|
||||
|
||||
// Commonlib's ordinary queue excludes every dot path when Hidden File
|
||||
// Sync is disabled. A Customisation-only event has already passed the
|
||||
// optional-file policy, so dispatch it without that unrelated gate.
|
||||
if (!this.settings.syncInternalFiles) {
|
||||
this.fileProcessing.onStorageFileEvent();
|
||||
await this.fileProcessing.processOptionalFileEvent(path);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.appendQueue(
|
||||
[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager", () => ({
|
||||
StorageEventManagerBase: class StorageEventManagerBase {
|
||||
settings: unknown;
|
||||
vaultService: unknown;
|
||||
fileProcessing: unknown;
|
||||
adapter: unknown;
|
||||
appendQueue = vi.fn(async () => undefined);
|
||||
|
||||
constructor(adapter: unknown, dependencies: Record<string, unknown>) {
|
||||
this.adapter = adapter;
|
||||
this.settings = dependencies.settings;
|
||||
this.vaultService = dependencies.vaultService;
|
||||
this.fileProcessing = dependencies.fileProcessing;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("./ObsidianStorageEventManagerAdapter", () => ({
|
||||
ObsidianStorageEventManagerAdapter: class ObsidianStorageEventManagerAdapter {
|
||||
converter = {
|
||||
toInternalFileInfo: vi.fn((path: FilePath) => ({ path, isInternal: true, isFolder: false })),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
import { StorageEventManagerObsidian } from "./StorageEventManagerObsidian.ts";
|
||||
|
||||
function createManager(
|
||||
options: {
|
||||
customisationEnabled?: boolean;
|
||||
hiddenFileEnabled?: boolean;
|
||||
target?: boolean;
|
||||
configured?: boolean;
|
||||
suspended?: boolean;
|
||||
maxMTime?: number;
|
||||
} = {}
|
||||
) {
|
||||
const settings = {
|
||||
usePluginSync: options.customisationEnabled ?? true,
|
||||
syncInternalFiles: options.hiddenFileEnabled ?? true,
|
||||
watchInternalFileChanges: true,
|
||||
isConfigured: options.configured ?? true,
|
||||
suspendFileWatching: options.suspended ?? false,
|
||||
maxMTimeForReflectEvents: options.maxMTime ?? 0,
|
||||
};
|
||||
const onStorageFileEvent = vi.fn();
|
||||
const processOptionalFileEvent = vi.fn(async () => true);
|
||||
const isTargetFileInExtra = vi.fn(async () => options.target ?? true);
|
||||
const core = {
|
||||
services: {
|
||||
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
|
||||
},
|
||||
};
|
||||
const manager = new StorageEventManagerObsidian(
|
||||
{} as never,
|
||||
core as never,
|
||||
{
|
||||
settings,
|
||||
vaultService: { isTargetFileInExtra },
|
||||
fileProcessing: { onStorageFileEvent, processOptionalFileEvent },
|
||||
} as never
|
||||
);
|
||||
const runRawEvent = (path: string) =>
|
||||
(manager as unknown as { _watchVaultRawEvents(path: FilePath): Promise<void> })._watchVaultRawEvents(
|
||||
path as FilePath
|
||||
);
|
||||
return {
|
||||
appendQueue: (manager as unknown as { appendQueue: ReturnType<typeof vi.fn> }).appendQueue,
|
||||
isTargetFileInExtra,
|
||||
onStorageFileEvent,
|
||||
processOptionalFileEvent,
|
||||
runRawEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("StorageEventManagerObsidian optional-file raw events", () => {
|
||||
it("dispatches a Customisation-only event without the base Hidden File Sync target gate", async () => {
|
||||
const fixture = createManager({ hiddenFileEnabled: false });
|
||||
|
||||
await fixture.runRawEvent(".obsidian/app.json");
|
||||
|
||||
expect(fixture.processOptionalFileEvent).toHaveBeenCalledWith(".obsidian/app.json");
|
||||
expect(fixture.onStorageFileEvent).toHaveBeenCalledOnce();
|
||||
expect(fixture.appendQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains the base queue for events while Hidden File Sync is enabled", async () => {
|
||||
const fixture = createManager();
|
||||
|
||||
await fixture.runRawEvent(".obsidian/workspace");
|
||||
|
||||
expect(fixture.appendQueue).toHaveBeenCalledOnce();
|
||||
expect(fixture.processOptionalFileEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dispatch disabled, filtered, folder, or out-of-directory paths", async () => {
|
||||
const disabled = createManager({ customisationEnabled: false, hiddenFileEnabled: false });
|
||||
await disabled.runRawEvent(".obsidian/app.json");
|
||||
expect(disabled.appendQueue).not.toHaveBeenCalled();
|
||||
|
||||
const filtered = createManager({ target: false });
|
||||
await filtered.runRawEvent(".obsidian/workspace");
|
||||
await filtered.runRawEvent(".obsidian/folder/");
|
||||
await filtered.runRawEvent("notes/example.md");
|
||||
expect(filtered.appendQueue).not.toHaveBeenCalled();
|
||||
expect(filtered.processOptionalFileEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["unconfigured", { configured: false }],
|
||||
["suspended", { suspended: true }],
|
||||
["bounded by reflection time", { maxMTime: 1 }],
|
||||
] as const)("preserves the base queue gate while %s", async (_label, options) => {
|
||||
const fixture = createManager({ hiddenFileEnabled: false, ...options });
|
||||
|
||||
await fixture.runRawEvent(".obsidian/app.json");
|
||||
|
||||
expect(fixture.isTargetFileInExtra).not.toHaveBeenCalled();
|
||||
expect(fixture.processOptionalFileEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,33 @@ import { ObsidianLiveSyncSettingTab } from "./SettingDialogue/ObsidianLiveSyncSe
|
||||
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
|
||||
import { EVENT_REQUEST_OPEN_SETTINGS, eventHub } from "@/common/events.ts";
|
||||
import type ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { openObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
import type { HiddenFileSyncRepairView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
|
||||
|
||||
export type ModuleObsidianSettingDialogueDependencies = {
|
||||
getHiddenFileSyncRepair(): HiddenFileSyncRepairView | undefined;
|
||||
};
|
||||
|
||||
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||
private readonly dependencies: ModuleObsidianSettingDialogueDependencies;
|
||||
settingTab!: ObsidianLiveSyncSettingTab;
|
||||
|
||||
constructor(
|
||||
plugin: ObsidianLiveSyncPlugin,
|
||||
core: LiveSyncCore,
|
||||
dependencies: Partial<ModuleObsidianSettingDialogueDependencies> = {}
|
||||
) {
|
||||
super(plugin, core);
|
||||
this.dependencies = {
|
||||
getHiddenFileSyncRepair: dependencies.getHiddenFileSyncRepair ?? (() => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
_everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
|
||||
const hiddenFileSyncRepair = this.dependencies.getHiddenFileSyncRepair();
|
||||
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin, { hiddenFileSyncRepair });
|
||||
this.settingTab.reloadAllSettings(true);
|
||||
this.plugin.addSettingTab(this.settingTab);
|
||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const settingTabState = vi.hoisted(() => ({
|
||||
callOrder: [] as string[],
|
||||
featureViews: undefined as unknown,
|
||||
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
|
||||
}));
|
||||
|
||||
@@ -11,6 +12,10 @@ const eventHubState = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
|
||||
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
|
||||
constructor(_app: unknown, _plugin: unknown, featureViews: unknown) {
|
||||
settingTabState.featureViews = featureViews;
|
||||
}
|
||||
|
||||
reloadAllSettings(skipUpdate?: boolean) {
|
||||
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
|
||||
settingTabState.reloadAllSettings(skipUpdate);
|
||||
@@ -46,15 +51,20 @@ function createModuleHarness() {
|
||||
},
|
||||
},
|
||||
};
|
||||
const hiddenFileSyncRepair = { scanInternalFiles: vi.fn() };
|
||||
const getHiddenFileSyncRepair = vi.fn(() => hiddenFileSyncRepair);
|
||||
const module = Object.assign(Object.create(ModuleObsidianSettingDialogue.prototype), {
|
||||
plugin,
|
||||
core: { services },
|
||||
dependencies: { getHiddenFileSyncRepair },
|
||||
}) as ModuleObsidianSettingDialogue;
|
||||
|
||||
module.onBindFunction(module.core as never, services as never);
|
||||
|
||||
return {
|
||||
initialisationHandler: () => initialisationHandler,
|
||||
getHiddenFileSyncRepair,
|
||||
hiddenFileSyncRepair,
|
||||
module,
|
||||
plugin,
|
||||
services,
|
||||
@@ -65,6 +75,7 @@ function createModuleHarness() {
|
||||
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
settingTabState.callOrder.length = 0;
|
||||
settingTabState.featureViews = undefined;
|
||||
settingTabState.reloadAllSettings.mockClear();
|
||||
eventHubState.onEvent.mockClear();
|
||||
});
|
||||
@@ -79,7 +90,8 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
});
|
||||
|
||||
it("seeds the setting editor without requesting a render before registration", async () => {
|
||||
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
|
||||
const { getHiddenFileSyncRepair, hiddenFileSyncRepair, initialisationHandler, settingsLoadedHandler } =
|
||||
createModuleHarness();
|
||||
const handler = settingsLoadedHandler() ?? initialisationHandler();
|
||||
|
||||
expect(handler).toBeTypeOf("function");
|
||||
@@ -87,5 +99,7 @@ describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
|
||||
|
||||
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
|
||||
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
|
||||
expect(getHiddenFileSyncRepair).toHaveBeenCalledOnce();
|
||||
expect(settingTabState.featureViews).toEqual({ hiddenFileSyncRepair });
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { testCrypt } from "octagonal-wheels/encryption/encryption";
|
||||
import ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import type ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import { scheduleTask } from "@/common/utils.ts";
|
||||
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
@@ -78,12 +78,18 @@ import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
|
||||
import type { HiddenFileSyncRepairView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
|
||||
|
||||
// For creating a document
|
||||
// const toc = new Set<string>();
|
||||
|
||||
export type SettingTabFeatureViews = {
|
||||
hiddenFileSyncRepair?: HiddenFileSyncRepairView;
|
||||
};
|
||||
|
||||
export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
readonly featureViews: SettingTabFeatureViews;
|
||||
private _lifetimeComponent?: Component;
|
||||
private activePageRefresh?: () => void;
|
||||
get lifetimeComponent(): Component {
|
||||
@@ -323,9 +329,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
controlledElementFunc = [] as UpdateFunction[];
|
||||
onSavedHandlers = [] as OnSavedHandler<AllSettingItemKey>[];
|
||||
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin, featureViews: SettingTabFeatureViews = {}) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
this.featureViews = featureViews;
|
||||
Setting.env = this;
|
||||
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
|
||||
this.requestReload();
|
||||
|
||||
@@ -67,8 +67,6 @@ export function paneCustomisationSync(
|
||||
.setButtonText("Open")
|
||||
.setDisabled(false)
|
||||
.onClick(() => {
|
||||
// this.plugin.getAddOn<ConfigSync>(ConfigSync.name)?.showPluginSyncModal();
|
||||
// this.plugin.addOnConfigSync.showPluginSyncModal();
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG);
|
||||
});
|
||||
})
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
|
||||
eventHub,
|
||||
} from "@/common/events.ts";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
@@ -339,22 +338,22 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
]);
|
||||
};
|
||||
const findHiddenFile = async (path: string) => {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
if (!addOn) {
|
||||
const repair = this.featureViews.hiddenFileSyncRepair;
|
||||
if (!repair) {
|
||||
return false;
|
||||
}
|
||||
const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path);
|
||||
const file = (await repair.scanInternalFiles()).find((entry) => entry.path === path);
|
||||
if (!file) {
|
||||
Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return { addOn, file };
|
||||
return { repair, file };
|
||||
};
|
||||
const storeStorageInDatabase = async (path: string): Promise<boolean> => {
|
||||
if (path.startsWith(".")) {
|
||||
const hidden = await findHiddenFile(path);
|
||||
return hidden
|
||||
? Boolean(await hidden.addOn.storeInternalFileToDatabase(hidden.file, true))
|
||||
? Boolean(await hidden.repair.storeInternalFileToDatabase(hidden.file, true))
|
||||
: false;
|
||||
}
|
||||
return Boolean(await this.core.fileHandler.storeFileToDB(path as FilePath, true));
|
||||
@@ -368,7 +367,7 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
const hidden = await findHiddenFile(path);
|
||||
return hidden
|
||||
? Boolean(
|
||||
await hidden.addOn.storeInternalFileToDatabaseWithBaseRevision(
|
||||
await hidden.repair.storeInternalFileToDatabaseWithBaseRevision(
|
||||
hidden.file,
|
||||
revision,
|
||||
createIfDifferent
|
||||
@@ -390,10 +389,10 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
force: boolean
|
||||
): Promise<boolean> => {
|
||||
if (path.startsWith(".")) {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
return addOn
|
||||
const repair = this.featureViews.hiddenFileSyncRepair;
|
||||
return repair
|
||||
? Boolean(
|
||||
await addOn.extractInternalFileRevisionFromDatabase(
|
||||
await repair.extractInternalFileRevisionFromDatabase(
|
||||
path as FilePath,
|
||||
revision,
|
||||
force
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { pluginScanningCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
|
||||
import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
import { Platform } from "@/deps.ts";
|
||||
import type { CustomisationSyncContextDependencies } from "@/features/ConfigSync/customisationSyncContext.ts";
|
||||
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
||||
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
|
||||
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
|
||||
|
||||
export type CustomisationSyncObsidianPolicies = Pick<
|
||||
CustomisationSyncContextDependencies,
|
||||
"getUIControl" | "ownsLocalDocument" | "ownsLocalFile"
|
||||
>;
|
||||
|
||||
function fallbackDeviceName(): string {
|
||||
let name: string;
|
||||
if (Platform.isAndroidApp) {
|
||||
name = "android-app";
|
||||
} else if (Platform.isIosApp) {
|
||||
name = "ios";
|
||||
} else if (Platform.isMacOS) {
|
||||
name = "macos";
|
||||
} else if (Platform.isMobileApp) {
|
||||
name = "mobile-app";
|
||||
} else if (Platform.isMobile) {
|
||||
name = "mobile";
|
||||
} else if (Platform.isSafari) {
|
||||
name = "safari";
|
||||
} else if (Platform.isDesktop) {
|
||||
name = "desktop";
|
||||
} else if (Platform.isDesktopApp) {
|
||||
name = "desktop-app";
|
||||
} else {
|
||||
name = "unknown";
|
||||
}
|
||||
return name + Math.random().toString(36).slice(-4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt the Obsidian host to the narrow capabilities used by Customisation Sync.
|
||||
*
|
||||
* Runtime state and synchronisation policy remain in the context. Obsidian UI,
|
||||
* host lifecycle, and compatibility telemetry stay at this composition edge.
|
||||
*/
|
||||
export function createCustomisationSyncObsidianDependencies(
|
||||
host: LiveSyncCore,
|
||||
policies: CustomisationSyncObsidianPolicies
|
||||
): CustomisationSyncContextDependencies {
|
||||
const { services } = host;
|
||||
const app = services.context.app;
|
||||
const log = createInstanceLogFunction("CustomisationSyncContext", services.API);
|
||||
|
||||
return {
|
||||
getSettings: () => services.setting.settings,
|
||||
getLocalDatabase: () => services.database.localDatabase,
|
||||
storageAccess: host.serviceModules.storageAccess,
|
||||
path: services.path,
|
||||
log,
|
||||
getConfigDir: () => services.API.getSystemConfigDir(),
|
||||
getDeviceAndVaultName: () => services.setting.getDeviceAndVaultName(),
|
||||
setDeviceAndVaultName: (name) => services.setting.setDeviceAndVaultName(name),
|
||||
saveSettingData: async () => await services.setting.saveSettingData(),
|
||||
applySettings: async (partial, saveImmediately) =>
|
||||
await services.setting.applyPartial(partial, saveImmediately),
|
||||
replicateUserInitiated: async (options) => await services.replication.replicateUserInitiated(options),
|
||||
askString: async (title, key, placeholder) => await services.UI.confirm.askString(title, key, placeholder),
|
||||
isReady: () => services.appLifecycle.isReady(),
|
||||
isSuspended: () => services.appLifecycle.isSuspended(),
|
||||
askRestart: () => services.appLifecycle.askRestart(),
|
||||
createPeriodicProcessor: (process) => new PeriodicProcessor(host, process),
|
||||
listFiles: async (path) => await app.vault.adapter.list(path),
|
||||
resolveJsonConflict: async (path, files, remoteName, apply) =>
|
||||
await new Promise<boolean>((resolve) => {
|
||||
const modal = new JsonResolveModal(
|
||||
app,
|
||||
path,
|
||||
files,
|
||||
async (_keep, result) => {
|
||||
if (result == null) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(await apply(result));
|
||||
},
|
||||
"Local",
|
||||
remoteName,
|
||||
"B",
|
||||
true,
|
||||
true,
|
||||
"Difference between local and remote"
|
||||
);
|
||||
modal.open();
|
||||
}),
|
||||
selectTextFile: async (path, diffResult, remoteName) => {
|
||||
const modal = new ConflictResolveModal(app, path, diffResult, true, remoteName);
|
||||
modal.open();
|
||||
const result = await modal.waitForResult();
|
||||
if (result === CANCELLED || result === LEAVE_TO_SUBSEQUENT) return false;
|
||||
if (result === "A" || result === "B") return result;
|
||||
return false;
|
||||
},
|
||||
reloadPlugin: async (configDir, pluginName) => {
|
||||
const pluginManager = getObsidianCommunityPluginManager(app);
|
||||
const pluginManifest = pluginManager.manifests.find(
|
||||
(manifest) =>
|
||||
pluginManager.enabledPlugins.has(manifest.id) &&
|
||||
manifest.dir == `${configDir}/plugins/${pluginName}`
|
||||
);
|
||||
if (!pluginManifest) return;
|
||||
|
||||
const logKey = "plugin-reload-" + pluginManifest.id;
|
||||
log(`Unloading plugin: ${pluginManifest.name}`, LOG_LEVEL_NOTICE, logKey);
|
||||
await pluginManager.unloadPlugin(pluginManifest.id);
|
||||
await pluginManager.loadPlugin(pluginManifest.id);
|
||||
log(`Plugin reloaded: ${pluginManifest.name}`, LOG_LEVEL_NOTICE, logKey);
|
||||
},
|
||||
getFallbackDeviceName: fallbackDeviceName,
|
||||
showConfigurationNotice: (openDialog) => {
|
||||
const fragment = createFragment((documentFragment) => {
|
||||
documentFragment.createSpan(undefined, (span) => {
|
||||
span.appendText("Some configuration has been arrived, Press ");
|
||||
span.appendChild(
|
||||
span.createEl("a", undefined, (anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", openDialog);
|
||||
})
|
||||
);
|
||||
span.appendText(" to open the config sync dialog , or press elsewhere to dismiss this message.");
|
||||
});
|
||||
});
|
||||
services.context.notices.show(UPDATED_CONFIGURATION_NOTICE_KEY, fragment, { durationMs: 20_000 });
|
||||
},
|
||||
hideConfigurationNotice: () => services.context.notices.hide(UPDATED_CONFIGURATION_NOTICE_KEY),
|
||||
getUIControl: policies.getUIControl,
|
||||
ownsLocalFile: (path: FilePath) => policies.ownsLocalFile(path),
|
||||
ownsLocalDocument: (path: FilePathWithPrefix) => policies.ownsLocalDocument(path),
|
||||
publishScanCount: (count) => {
|
||||
pluginScanningCount.value = count;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CANCELLED,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
conflictResult: "B" as string | symbol,
|
||||
conflictArguments: [] as unknown[],
|
||||
conflictOpen: vi.fn(),
|
||||
jsonArguments: [] as unknown[],
|
||||
jsonOpen: vi.fn(),
|
||||
jsonResult: "merged",
|
||||
log: vi.fn(),
|
||||
manager: {
|
||||
enabledPlugins: new Set(["example"]),
|
||||
manifests: [{ id: "example", name: "Example", dir: ".obsidian/plugins/example" }],
|
||||
loadPlugin: vi.fn(async () => undefined),
|
||||
unloadPlugin: vi.fn(async () => undefined),
|
||||
},
|
||||
periodicArguments: [] as unknown[],
|
||||
platform: {
|
||||
isAndroidApp: false,
|
||||
isIosApp: false,
|
||||
isMacOS: true,
|
||||
isMobileApp: false,
|
||||
isMobile: false,
|
||||
isSafari: false,
|
||||
isDesktop: true,
|
||||
isDesktopApp: true,
|
||||
},
|
||||
scanCount: { value: 0 },
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({ Platform: mocks.platform }));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {
|
||||
constructor(...args: unknown[]) {
|
||||
mocks.periodicArguments = args;
|
||||
}
|
||||
enable() {}
|
||||
disable() {}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(() => mocks.manager),
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {
|
||||
constructor(...args: unknown[]) {
|
||||
mocks.jsonArguments = args;
|
||||
}
|
||||
open() {
|
||||
mocks.jsonOpen();
|
||||
const callback = mocks.jsonArguments[3] as (keep?: string, result?: string) => Promise<void>;
|
||||
void callback(undefined, mocks.jsonResult);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {
|
||||
constructor(...args: unknown[]) {
|
||||
mocks.conflictArguments = args;
|
||||
}
|
||||
open() {
|
||||
mocks.conflictOpen();
|
||||
}
|
||||
async waitForResult() {
|
||||
return mocks.conflictResult;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores", () => ({
|
||||
pluginScanningCount: mocks.scanCount,
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/services/lib/logUtils", () => ({
|
||||
createInstanceLogFunction: vi.fn(() => mocks.log),
|
||||
}));
|
||||
|
||||
import { createCustomisationSyncObsidianDependencies } from "./customisationSyncObsidianAdapter.ts";
|
||||
|
||||
function createHost() {
|
||||
const listResult = { files: [".obsidian/app.json"], folders: [".obsidian/plugins"] };
|
||||
const list = vi.fn(async () => listResult);
|
||||
const notices = { show: vi.fn(), hide: vi.fn() };
|
||||
const firstSettings = { marker: "first" };
|
||||
const firstDatabase = { marker: "first-db" };
|
||||
const storageAccess = { marker: "storage" };
|
||||
const path = { marker: "path" };
|
||||
const services = {
|
||||
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
|
||||
appLifecycle: {
|
||||
askRestart: vi.fn(),
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
context: {
|
||||
app: { vault: { adapter: { list } } },
|
||||
notices,
|
||||
},
|
||||
database: { localDatabase: firstDatabase },
|
||||
path,
|
||||
replication: { replicateUserInitiated: vi.fn(async () => undefined) },
|
||||
setting: {
|
||||
settings: firstSettings,
|
||||
applyPartial: vi.fn(async () => undefined),
|
||||
getDeviceAndVaultName: vi.fn(() => "device-a"),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
setDeviceAndVaultName: vi.fn(),
|
||||
},
|
||||
UI: { confirm: { askString: vi.fn(async () => "device-b") } },
|
||||
};
|
||||
const host = { services, serviceModules: { storageAccess } };
|
||||
const getUIControl = vi.fn(() => undefined);
|
||||
const ownsLocalDocument = vi.fn(() => true);
|
||||
const ownsLocalFile = vi.fn(() => true);
|
||||
const dependencies = createCustomisationSyncObsidianDependencies(host as never, {
|
||||
getUIControl,
|
||||
ownsLocalDocument,
|
||||
ownsLocalFile,
|
||||
});
|
||||
return {
|
||||
dependencies,
|
||||
firstDatabase,
|
||||
firstSettings,
|
||||
getUIControl,
|
||||
host,
|
||||
list,
|
||||
listResult,
|
||||
notices,
|
||||
ownsLocalDocument,
|
||||
ownsLocalFile,
|
||||
path,
|
||||
services,
|
||||
storageAccess,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Customisation Sync Obsidian adapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.conflictResult = "B";
|
||||
mocks.jsonResult = "merged";
|
||||
mocks.scanCount.value = 0;
|
||||
});
|
||||
|
||||
it("provides live settings and database projections with explicit host telemetry", async () => {
|
||||
const fixture = createHost();
|
||||
|
||||
expect(fixture.dependencies.getSettings()).toBe(fixture.firstSettings);
|
||||
expect(fixture.dependencies.getLocalDatabase()).toBe(fixture.firstDatabase);
|
||||
expect(fixture.dependencies.storageAccess).toBe(fixture.storageAccess);
|
||||
expect(fixture.dependencies.path).toBe(fixture.path);
|
||||
|
||||
const replacementSettings = { marker: "replacement" };
|
||||
const replacementDatabase = { marker: "replacement-db" };
|
||||
fixture.services.setting.settings = replacementSettings;
|
||||
fixture.services.database.localDatabase = replacementDatabase;
|
||||
expect(fixture.dependencies.getSettings()).toBe(replacementSettings);
|
||||
expect(fixture.dependencies.getLocalDatabase()).toBe(replacementDatabase);
|
||||
|
||||
fixture.dependencies.publishScanCount(3);
|
||||
expect(mocks.scanCount.value).toBe(3);
|
||||
expect(fixture.dependencies.ownsLocalFile(".obsidian/app.json" as FilePath)).toBe(true);
|
||||
expect(fixture.dependencies.ownsLocalDocument("ix:device/app.json" as FilePathWithPrefix)).toBe(true);
|
||||
|
||||
const process = vi.fn(async () => undefined);
|
||||
fixture.dependencies.createPeriodicProcessor(process);
|
||||
expect(mocks.periodicArguments).toEqual([fixture.host, process]);
|
||||
await expect(fixture.dependencies.listFiles(".obsidian")).resolves.toBe(fixture.listResult);
|
||||
expect(fixture.list).toHaveBeenCalledWith(".obsidian");
|
||||
});
|
||||
|
||||
it("owns Obsidian conflict dialogue construction", async () => {
|
||||
const { dependencies, host } = createHost();
|
||||
const files = [{ path: "local.json" }, { path: "remote.json" }] as never;
|
||||
const apply = vi.fn(async () => true);
|
||||
|
||||
await expect(dependencies.resolveJsonConflict("app.json" as FilePath, files, "device-b", apply)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
expect(mocks.jsonOpen).toHaveBeenCalledOnce();
|
||||
expect(mocks.jsonArguments.slice(0, 3)).toEqual([host.services.context.app, "app.json", files]);
|
||||
expect(mocks.jsonArguments.slice(4)).toEqual([
|
||||
"Local",
|
||||
"device-b",
|
||||
"B",
|
||||
true,
|
||||
true,
|
||||
"Difference between local and remote",
|
||||
]);
|
||||
expect(apply).toHaveBeenCalledWith("merged");
|
||||
|
||||
await expect(dependencies.selectTextFile("app.css" as FilePath, {} as never, "device-b")).resolves.toBe("B");
|
||||
expect(mocks.conflictOpen).toHaveBeenCalledOnce();
|
||||
mocks.conflictResult = CANCELLED;
|
||||
await expect(dependencies.selectTextFile("app.css" as FilePath, {} as never, "device-b")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("owns plug-in reload, Notice, and fallback device-name effects", async () => {
|
||||
const { dependencies, notices } = createHost();
|
||||
let click: (() => void) | undefined;
|
||||
const fragment = {
|
||||
createSpan: (_options: unknown, build: (span: unknown) => void) => {
|
||||
const span = {
|
||||
appendText: vi.fn(),
|
||||
appendChild: vi.fn(),
|
||||
createEl: (_tag: string, _options: unknown, buildAnchor: (anchor: unknown) => void) => {
|
||||
const anchor = {
|
||||
text: "",
|
||||
addEventListener: (_event: string, callback: () => void) => {
|
||||
click = callback;
|
||||
},
|
||||
};
|
||||
buildAnchor(anchor);
|
||||
return anchor;
|
||||
},
|
||||
};
|
||||
build(span);
|
||||
},
|
||||
};
|
||||
vi.stubGlobal("createFragment", (build: (value: typeof fragment) => void) => {
|
||||
build(fragment);
|
||||
return fragment;
|
||||
});
|
||||
const openDialog = vi.fn();
|
||||
|
||||
await dependencies.reloadPlugin(".obsidian", "example");
|
||||
expect(mocks.manager.unloadPlugin).toHaveBeenCalledWith("example");
|
||||
expect(mocks.manager.loadPlugin).toHaveBeenCalledWith("example");
|
||||
expect(mocks.log).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"Unloading plugin: Example",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"plugin-reload-example"
|
||||
);
|
||||
|
||||
dependencies.showConfigurationNotice(openDialog);
|
||||
expect(notices.show).toHaveBeenCalledWith("config-sync:updated-configuration", fragment, {
|
||||
durationMs: 20_000,
|
||||
});
|
||||
click?.();
|
||||
expect(openDialog).toHaveBeenCalledOnce();
|
||||
dependencies.hideConfigurationNotice();
|
||||
expect(notices.hide).toHaveBeenCalledWith("config-sync:updated-configuration");
|
||||
expect(dependencies.getFallbackDeviceName()).toMatch(/^macos[a-z0-9]{4}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, type LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { fireAndForget, getFileRegExp, sendSignal } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import {
|
||||
hiddenFilesEventCount,
|
||||
hiddenFilesProcessingCount,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
|
||||
import { PeriodicProcessor } from "@/common/PeriodicProcessor.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
||||
import type {
|
||||
HiddenFileSyncContextDependencies,
|
||||
HiddenFileSyncJsonResolution,
|
||||
HiddenFileSyncProgress,
|
||||
} from "@/features/HiddenFileSync/hiddenFileSyncContext.ts";
|
||||
import { MARK_DONE } from "@/modules/features/ModuleLog.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
|
||||
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
||||
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
|
||||
|
||||
export type HiddenFileSyncObsidianPolicies = Pick<HiddenFileSyncContextDependencies, "ownsLocalFile">;
|
||||
|
||||
/** Adapt the Obsidian host to the narrow capabilities used by Hidden File Sync. */
|
||||
export function createHiddenFileSyncObsidianDependencies(
|
||||
host: LiveSyncCore,
|
||||
policies: HiddenFileSyncObsidianPolicies
|
||||
): HiddenFileSyncContextDependencies {
|
||||
const { services, serviceModules } = host;
|
||||
const app = services.context.app;
|
||||
const log = createInstanceLogFunction("HiddenFileSyncContext", services.API);
|
||||
let noticeIndex = 0;
|
||||
const activeConflictDialogs = new Map<string, symbol>();
|
||||
|
||||
const createProgress = (prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE): HiddenFileSyncProgress => {
|
||||
const key = `keepalive-progress-${noticeIndex++}`;
|
||||
return {
|
||||
log: (message) => log(prefix + message, level, key),
|
||||
once: (message) => log(prefix + message, level),
|
||||
done: (message: string = "Done") => log(prefix + message + MARK_DONE, level, key),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveJsonConflict: HiddenFileSyncContextDependencies["resolveJsonConflict"] = (path, docs, apply) =>
|
||||
new Promise<boolean>((resolve, reject) => {
|
||||
// Replacing a dialogue for the same path must close the old instance.
|
||||
const conflictPath = path;
|
||||
const token = Symbol(conflictPath);
|
||||
let settled = false;
|
||||
sendSignal(`cancel-internal-conflict:${conflictPath}`);
|
||||
activeConflictDialogs.set(conflictPath, token);
|
||||
const modal = new JsonResolveModal(app, path, docs, async (keepRevision, mergedText) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const resolution: HiddenFileSyncJsonResolution = { keepRevision, mergedText };
|
||||
try {
|
||||
resolve(await apply(resolution));
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
} finally {
|
||||
if (activeConflictDialogs.get(conflictPath) === token) {
|
||||
activeConflictDialogs.delete(conflictPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
modal.open();
|
||||
});
|
||||
|
||||
const showConfigurationChangeNotice = (updatedFolders: readonly string[]) => {
|
||||
const noticeGroups = services.context.noticeGroups;
|
||||
let hasNoticeItems = false;
|
||||
try {
|
||||
const pluginManager = getObsidianCommunityPluginManager(app);
|
||||
const enabledPluginManifests = pluginManager.manifests.filter((manifest) =>
|
||||
pluginManager.enabledPlugins.has(manifest.id)
|
||||
);
|
||||
const modifiedManifests = enabledPluginManifests.filter((manifest) =>
|
||||
updatedFolders.includes(manifest.dir ?? "")
|
||||
);
|
||||
for (const manifest of modifiedManifests) {
|
||||
const pluginId = manifest.id;
|
||||
const pluginName = manifest.name;
|
||||
const itemKey = `plugin:${pluginId}`;
|
||||
noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, itemKey, {
|
||||
message: `Files in ${pluginName} were updated.`,
|
||||
action: {
|
||||
label: `Reload ${pluginName}`,
|
||||
onSelect: () => {
|
||||
fireAndForget(async () => {
|
||||
const logKey = `plugin-reload-${pluginId}`;
|
||||
log(`Unloading plugin: ${pluginName}`, LOG_LEVEL_NOTICE, logKey);
|
||||
await pluginManager.unloadPlugin(pluginId);
|
||||
await pluginManager.loadPlugin(pluginId);
|
||||
log(`Plugin reloaded: ${pluginName}`, LOG_LEVEL_NOTICE, logKey);
|
||||
noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, itemKey);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
hasNoticeItems = true;
|
||||
}
|
||||
} catch (error) {
|
||||
log("Error on checking plugin status.");
|
||||
log(error, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
|
||||
if (updatedFolders.includes(services.API.getSystemConfigDir())) {
|
||||
if (!services.appLifecycle.isReloadingScheduled()) {
|
||||
noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, "restart", {
|
||||
message: "Other Obsidian settings files were updated.",
|
||||
action: {
|
||||
label: "Schedule an Obsidian restart",
|
||||
onSelect: () => {
|
||||
services.appLifecycle.scheduleRestart();
|
||||
noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, "restart");
|
||||
},
|
||||
},
|
||||
});
|
||||
hasNoticeItems = true;
|
||||
} else {
|
||||
noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, "restart");
|
||||
}
|
||||
}
|
||||
if (hasNoticeItems) {
|
||||
noticeGroups.finish(HIDDEN_FILE_NOTICE_GROUP, { durationMs: HIDDEN_FILE_NOTICE_DURATION_MS });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getSettings: () => services.setting.settings,
|
||||
getLocalDatabase: () => services.database.localDatabase,
|
||||
getKeyValueDatabase: () => services.keyValueDB.kvDB,
|
||||
storageAccess: serviceModules.storageAccess,
|
||||
databaseFileAccess: serviceModules.databaseFileAccess,
|
||||
path: services.path,
|
||||
log,
|
||||
createProgress,
|
||||
createPeriodicProcessor: (process) => new PeriodicProcessor(host, process),
|
||||
isReady: () => services.appLifecycle.isReady(),
|
||||
isSuspended: () => services.appLifecycle.isSuspended(),
|
||||
isDatabaseReady: () => services.database.isDatabaseReady(),
|
||||
isIgnoredByIgnoreFile: async (path) => await services.vault.isIgnoredByIgnoreFile(path),
|
||||
getConfigDir: () => services.API.getSystemConfigDir(),
|
||||
getRootPath: () => app.vault.getRoot().path,
|
||||
listFiles: async (path) => await app.vault.adapter.list(path),
|
||||
getFileRegExp: (key) => getFileRegExp(services.setting.settings, key),
|
||||
applySettings: async (partial, saveImmediately) =>
|
||||
await services.setting.applyPartial(partial, saveImmediately),
|
||||
setSyncInternalFilesEnabled: (enabled) => {
|
||||
services.setting.settings.syncInternalFiles = enabled;
|
||||
},
|
||||
resolveJsonConflict,
|
||||
showConfigurationChangeNotice,
|
||||
hideConfigurationChangeNotice: () => {
|
||||
services.context.noticeGroups.hide(HIDDEN_FILE_NOTICE_GROUP);
|
||||
},
|
||||
closeJsonConflictDialogs: () => {
|
||||
for (const path of activeConflictDialogs.keys()) {
|
||||
sendSignal(`cancel-internal-conflict:${path}`);
|
||||
}
|
||||
activeConflictDialogs.clear();
|
||||
},
|
||||
publishActivity: (eventCount, processingCount) => {
|
||||
hiddenFilesEventCount.value = eventCount;
|
||||
hiddenFilesProcessingCount.value = processingCount;
|
||||
},
|
||||
ownsLocalFile: (path) => policies.ownsLocalFile(path),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
hiddenFilesEventCount,
|
||||
hiddenFilesProcessingCount,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
log: vi.fn(),
|
||||
sendSignal: vi.fn(),
|
||||
getFileRegExp: vi.fn(() => []),
|
||||
openModal: vi.fn(),
|
||||
modal: {
|
||||
resolve: undefined as ((keepRevision?: string, mergedText?: string) => Promise<void>) | undefined,
|
||||
},
|
||||
pluginManager: {
|
||||
manifests: [
|
||||
{ id: "alpha", name: "Alpha", dir: ".obsidian/plugins/alpha" },
|
||||
{ id: "beta", name: "Beta", dir: ".obsidian/plugins/beta" },
|
||||
],
|
||||
enabledPlugins: new Set(["alpha", "beta"]),
|
||||
unloadPlugin: vi.fn(async () => undefined),
|
||||
loadPlugin: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", () => ({
|
||||
fireAndForget: (operation: () => Promise<unknown>) => void operation(),
|
||||
getFileRegExp: mocks.getFileRegExp,
|
||||
sendSignal: mocks.sendSignal,
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/services/lib/logUtils", () => ({
|
||||
createInstanceLogFunction: () => mocks.log,
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {
|
||||
constructor(
|
||||
_app: unknown,
|
||||
_path: unknown,
|
||||
_docs: unknown,
|
||||
callback: (keepRevision?: string, mergedText?: string) => Promise<void>
|
||||
) {
|
||||
mocks.modal.resolve = callback;
|
||||
}
|
||||
open() {
|
||||
mocks.openModal();
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "<done>" }));
|
||||
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: () => mocks.pluginManager,
|
||||
}));
|
||||
|
||||
import { createHiddenFileSyncObsidianDependencies } from "./hiddenFileSyncObsidianAdapter.ts";
|
||||
|
||||
function createFixture() {
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(() => true),
|
||||
removeItem: vi.fn(() => true),
|
||||
hide: vi.fn(() => true),
|
||||
};
|
||||
const scheduleRestart = vi.fn();
|
||||
const settings = {
|
||||
syncInternalFiles: true,
|
||||
syncInternalFileOverwritePatterns: "",
|
||||
syncInternalFilesIgnorePatterns: "",
|
||||
syncInternalFilesTargetPatterns: "",
|
||||
};
|
||||
const app = {
|
||||
vault: {
|
||||
getRoot: vi.fn(() => ({ path: "" })),
|
||||
adapter: { list: vi.fn(async () => ({ files: [], folders: [] })) },
|
||||
},
|
||||
};
|
||||
const host = {
|
||||
services: {
|
||||
context: { app, noticeGroups },
|
||||
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
|
||||
appLifecycle: {
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
isReloadingScheduled: vi.fn(() => false),
|
||||
scheduleRestart,
|
||||
},
|
||||
database: { localDatabase: {}, isDatabaseReady: vi.fn(() => true) },
|
||||
keyValueDB: { kvDB: {} },
|
||||
path: {},
|
||||
setting: { settings, applyPartial: vi.fn(async () => undefined) },
|
||||
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
|
||||
},
|
||||
serviceModules: {
|
||||
storageAccess: {},
|
||||
databaseFileAccess: {},
|
||||
},
|
||||
};
|
||||
const ownsLocalFile = vi.fn(() => true);
|
||||
const dependencies = createHiddenFileSyncObsidianDependencies(host as never, { ownsLocalFile });
|
||||
return { dependencies, noticeGroups, scheduleRestart, settings };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.modal.resolve = undefined;
|
||||
hiddenFilesEventCount.value = 0;
|
||||
hiddenFilesProcessingCount.value = 0;
|
||||
});
|
||||
|
||||
describe("Hidden File Sync Obsidian adapter", () => {
|
||||
it("owns grouped plug-in reload and application restart Notices", async () => {
|
||||
const { dependencies, noticeGroups, scheduleRestart } = createFixture();
|
||||
|
||||
dependencies.showConfigurationChangeNotice([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]);
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "hidden-file-changes", "plugin:alpha", {
|
||||
message: "Files in Alpha were updated.",
|
||||
action: expect.objectContaining({ label: "Reload Alpha" }),
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "hidden-file-changes", "plugin:beta", {
|
||||
message: "Files in Beta were updated.",
|
||||
action: expect.objectContaining({ label: "Reload Beta" }),
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(3, "hidden-file-changes", "restart", {
|
||||
message: "Other Obsidian settings files were updated.",
|
||||
action: expect.objectContaining({ label: "Schedule an Obsidian restart" }),
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("hidden-file-changes", { durationMs: 20_000 });
|
||||
|
||||
const reload = (noticeGroups.setItem.mock.calls[0]?.[2] as { action: { onSelect(): void } }).action.onSelect;
|
||||
reload();
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.pluginManager.unloadPlugin).toHaveBeenCalledWith("alpha");
|
||||
expect(mocks.pluginManager.loadPlugin).toHaveBeenCalledWith("alpha");
|
||||
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "plugin:alpha");
|
||||
});
|
||||
|
||||
const restart = (noticeGroups.setItem.mock.calls[2]?.[2] as { action: { onSelect(): void } }).action.onSelect;
|
||||
restart();
|
||||
expect(scheduleRestart).toHaveBeenCalledOnce();
|
||||
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart");
|
||||
|
||||
dependencies.hideConfigurationChangeNotice();
|
||||
expect(noticeGroups.hide).toHaveBeenCalledWith("hidden-file-changes");
|
||||
});
|
||||
|
||||
it("owns the conflict dialogue, progress presentation, and compatibility activity publication", async () => {
|
||||
const { dependencies } = createFixture();
|
||||
const docs = [{ path: "i:.obsidian/app.json" }, { path: "i:.obsidian/app.json" }] as never;
|
||||
const apply = vi.fn(async () => true);
|
||||
|
||||
const resolution = dependencies.resolveJsonConflict(".obsidian/app.json" as never, docs, apply);
|
||||
expect(mocks.sendSignal).toHaveBeenCalledWith("cancel-internal-conflict:.obsidian/app.json");
|
||||
expect(mocks.openModal).toHaveBeenCalledOnce();
|
||||
await mocks.modal.resolve?.("2-selected", "merged");
|
||||
await expect(resolution).resolves.toBe(true);
|
||||
expect(apply).toHaveBeenCalledWith({ keepRevision: "2-selected", mergedText: "merged" });
|
||||
|
||||
const progress = dependencies.createProgress("Prefix: ", LOG_LEVEL_NOTICE);
|
||||
progress.log("Working");
|
||||
progress.once("Once");
|
||||
progress.done();
|
||||
expect(mocks.log).toHaveBeenNthCalledWith(1, "Prefix: Working", LOG_LEVEL_NOTICE, "keepalive-progress-0");
|
||||
expect(mocks.log).toHaveBeenNthCalledWith(2, "Prefix: Once", LOG_LEVEL_NOTICE);
|
||||
expect(mocks.log).toHaveBeenNthCalledWith(3, "Prefix: Done<done>", LOG_LEVEL_NOTICE, "keepalive-progress-0");
|
||||
|
||||
dependencies.publishActivity(3, 1);
|
||||
expect(hiddenFilesEventCount.value).toBe(3);
|
||||
expect(hiddenFilesProcessingCount.value).toBe(1);
|
||||
});
|
||||
|
||||
it("closes each active conflict dialogue with the path used by the modal", async () => {
|
||||
const { dependencies } = createFixture();
|
||||
const docs = [{ path: "i:.obsidian/app.json" }, { path: "i:.obsidian/app.json" }] as never;
|
||||
const apply = vi.fn(async () => false);
|
||||
|
||||
const resolution = dependencies.resolveJsonConflict(".obsidian/app.json" as never, docs, apply);
|
||||
dependencies.closeJsonConflictDialogs();
|
||||
|
||||
expect(mocks.sendSignal).toHaveBeenNthCalledWith(1, "cancel-internal-conflict:.obsidian/app.json");
|
||||
expect(mocks.sendSignal).toHaveBeenNthCalledWith(2, "cancel-internal-conflict:.obsidian/app.json");
|
||||
|
||||
await mocks.modal.resolve?.();
|
||||
await expect(resolution).resolves.toBe(false);
|
||||
expect(apply).toHaveBeenCalledWith({ keepRevision: undefined, mergedText: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
|
||||
describe("Optional File Sync ownership boundary", () => {
|
||||
it("keeps the concrete runtimes behind the composition feature", () => {
|
||||
const mainSource = read("../main.ts");
|
||||
const featureSource = read("./useOptionalFileSync.ts");
|
||||
const customisationAdapterSource = read("./customisationSyncObsidianAdapter.ts");
|
||||
const hiddenFileAdapterSource = read("./hiddenFileSyncObsidianAdapter.ts");
|
||||
const customisationSource = read("../features/ConfigSync/customisationSyncContext.ts");
|
||||
const hiddenSource = read("../features/HiddenFileSync/hiddenFileSyncContext.ts");
|
||||
|
||||
expect(mainSource).not.toContain("new CustomisationSyncContext");
|
||||
expect(mainSource).not.toContain("new HiddenFileSyncContext");
|
||||
expect(mainSource).not.toContain("optionalFileSync.testing");
|
||||
expect(featureSource).toContain("new CustomisationSyncContext");
|
||||
expect(featureSource).toContain("new HiddenFileSyncContext");
|
||||
expect(customisationSource).not.toContain("onBindFunction(");
|
||||
expect(hiddenSource).not.toContain("onBindFunction(");
|
||||
expect(customisationSource).not.toContain("LiveSyncCommands");
|
||||
expect(hiddenSource).not.toContain("LiveSyncCommands");
|
||||
expect(customisationSource).not.toContain("extends LiveSyncContext");
|
||||
expect(customisationSource).not.toMatch(/from ["'][^"']*LiveSyncContext(?:\.ts)?["']/);
|
||||
expect(customisationSource).not.toMatch(/from ["'][^"']*main(?:\.ts)?["']/);
|
||||
expect(customisationSource).not.toMatch(/\bthis\.(?:app|core|services)\b/);
|
||||
expect(customisationSource).not.toContain("JsonResolveModal");
|
||||
expect(customisationSource).not.toContain("ConflictResolveModal");
|
||||
expect(customisationSource).not.toContain("getObsidianCommunityPluginManager");
|
||||
expect(customisationSource).not.toContain("pluginScanningCount");
|
||||
expect(featureSource).toContain("createCustomisationSyncObsidianDependencies");
|
||||
expect(customisationAdapterSource).toContain("JsonResolveModal");
|
||||
expect(customisationAdapterSource).toContain("ConflictResolveModal");
|
||||
expect(hiddenSource).not.toContain("extends LiveSyncContext");
|
||||
expect(hiddenSource).not.toMatch(/from ["'][^"']*LiveSyncContext(?:\.ts)?["']/);
|
||||
expect(hiddenSource).not.toMatch(/from ["'][^"']*main(?:\.ts)?["']/);
|
||||
expect(hiddenSource).not.toMatch(/\bthis\.(?:app|core|services)\b/);
|
||||
expect(hiddenSource).not.toContain("JsonResolveModal");
|
||||
expect(hiddenSource).not.toContain("getObsidianCommunityPluginManager");
|
||||
expect(hiddenSource).not.toContain("hiddenFilesEventCount");
|
||||
expect(hiddenSource).not.toContain("hiddenFilesProcessingCount");
|
||||
expect(featureSource).toContain("createHiddenFileSyncObsidianDependencies");
|
||||
expect(hiddenFileAdapterSource).toContain("JsonResolveModal");
|
||||
expect(hiddenFileAdapterSource).toContain("getObsidianCommunityPluginManager");
|
||||
expect(hiddenFileAdapterSource).toContain("hiddenFilesEventCount");
|
||||
expect(hiddenFileAdapterSource).toContain("hiddenFilesProcessingCount");
|
||||
});
|
||||
|
||||
it("removes constructor-name add-on lookup from the core", () => {
|
||||
const coreSource = read("../LiveSyncBaseCore.ts");
|
||||
|
||||
expect(coreSource).not.toContain("getAddOn");
|
||||
expect(coreSource).not.toContain("addon.constructor.name");
|
||||
});
|
||||
|
||||
it("keeps real-Obsidian tests on the explicit feature test surface", () => {
|
||||
const customisationE2e = read("../../test/e2e-obsidian/scripts/customisation-sync.ts");
|
||||
const hiddenE2e = read("../../test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts");
|
||||
const setupE2e = read("../../test/e2e-obsidian/scripts/setup-uri-workflow.ts");
|
||||
|
||||
for (const source of [customisationE2e, hiddenE2e, setupE2e]) {
|
||||
expect(source).not.toContain("getAddOn(");
|
||||
}
|
||||
expect(customisationE2e).toContain("optionalFileSync.testing.customisationSync");
|
||||
expect(hiddenE2e).toContain("optionalFileSync.testing.hiddenFileSync");
|
||||
expect(setupE2e).toContain("optionalFileSync.testing.hiddenFileSync");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
MODE_AUTOMATIC,
|
||||
MODE_PAUSED,
|
||||
MODE_SELECTIVE,
|
||||
MODE_SHINY,
|
||||
type PluginSyncSettingEntry,
|
||||
type SYNC_MODE,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import {
|
||||
getCustomisationSyncFileCategory,
|
||||
getCustomisationSyncSettingKey,
|
||||
getCustomisationSyncSettingKeyFromDocumentPath,
|
||||
type CustomisationSyncFileCategory,
|
||||
type CustomisationSyncPathOptions,
|
||||
} from "@/features/ConfigSync/customisationSyncPaths.ts";
|
||||
|
||||
export type OptionalFileSyncOwner = "customisation" | "hidden-file" | "none";
|
||||
|
||||
export type OptionalFileSyncRoutingReason =
|
||||
| "customisation-selective"
|
||||
| "customisation-flagged-selective"
|
||||
| "customisation-paused"
|
||||
| "customisation-not-ready"
|
||||
| "hidden-file-automatic"
|
||||
| "hidden-file-path"
|
||||
| "hidden-file-disabled"
|
||||
| "hidden-file-not-ready"
|
||||
| "hidden-file-filtered"
|
||||
| "features-disabled"
|
||||
| "unsupported-path";
|
||||
|
||||
export type OptionalFileSyncRoutingDecision = {
|
||||
owner: OptionalFileSyncOwner;
|
||||
reason: OptionalFileSyncRoutingReason;
|
||||
category: CustomisationSyncFileCategory;
|
||||
settingKey?: string;
|
||||
mode?: SYNC_MODE;
|
||||
};
|
||||
|
||||
export type OptionalFileSyncOwnerSelectionInput = CustomisationSyncPathOptions & {
|
||||
path: string;
|
||||
customisationEnabled: boolean;
|
||||
hiddenFileEnabled: boolean;
|
||||
pluginSyncExtendedSetting: Readonly<Record<string, PluginSyncSettingEntry>>;
|
||||
};
|
||||
|
||||
export type OptionalFileSyncRoutingInput = OptionalFileSyncOwnerSelectionInput & {
|
||||
customisationReady: boolean;
|
||||
hiddenFileReady: boolean;
|
||||
hiddenFileEligible: boolean;
|
||||
};
|
||||
|
||||
export type CustomisationSyncDocumentOwnershipInput = {
|
||||
documentPath: FilePathWithPrefix;
|
||||
customisationEnabled: boolean;
|
||||
pluginSyncExtendedSetting: Readonly<Record<string, PluginSyncSettingEntry>>;
|
||||
};
|
||||
|
||||
function isHiddenFileSyncPath(path: string): boolean {
|
||||
return path.startsWith(".") && !path.startsWith(".trash");
|
||||
}
|
||||
|
||||
/** Select the sole local writer from persisted feature settings. */
|
||||
export function selectOptionalFileSyncOwner(
|
||||
input: OptionalFileSyncOwnerSelectionInput
|
||||
): OptionalFileSyncRoutingDecision {
|
||||
const pathOptions: CustomisationSyncPathOptions = input;
|
||||
const settingKey = getCustomisationSyncSettingKey(input.path, pathOptions);
|
||||
const category = settingKey ? getCustomisationSyncFileCategory(input.path, pathOptions) : "";
|
||||
|
||||
if (!input.customisationEnabled && !input.hiddenFileEnabled) {
|
||||
return { owner: "none", reason: "features-disabled", category, settingKey };
|
||||
}
|
||||
|
||||
if (settingKey && input.customisationEnabled) {
|
||||
const mode = input.pluginSyncExtendedSetting[settingKey]?.mode ?? MODE_SELECTIVE;
|
||||
if (mode == MODE_SELECTIVE) {
|
||||
return { owner: "customisation", reason: "customisation-selective", category, settingKey, mode };
|
||||
}
|
||||
if (mode == MODE_SHINY) {
|
||||
return {
|
||||
owner: "customisation",
|
||||
reason: "customisation-flagged-selective",
|
||||
category,
|
||||
settingKey,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
if (mode == MODE_PAUSED) {
|
||||
return { owner: "none", reason: "customisation-paused", category, settingKey, mode };
|
||||
}
|
||||
if (mode == MODE_AUTOMATIC) {
|
||||
return input.hiddenFileEnabled
|
||||
? { owner: "hidden-file", reason: "hidden-file-automatic", category, settingKey, mode }
|
||||
: { owner: "none", reason: "hidden-file-disabled", category, settingKey, mode };
|
||||
}
|
||||
}
|
||||
|
||||
if (input.hiddenFileEnabled && isHiddenFileSyncPath(input.path)) {
|
||||
return { owner: "hidden-file", reason: "hidden-file-path", category, settingKey };
|
||||
}
|
||||
|
||||
return { owner: "none", reason: "unsupported-path", category, settingKey };
|
||||
}
|
||||
|
||||
/** Apply transient readiness and asynchronous Hidden File Sync eligibility to the selected owner. */
|
||||
export function routeOptionalFileSyncPath(input: OptionalFileSyncRoutingInput): OptionalFileSyncRoutingDecision {
|
||||
const selected = selectOptionalFileSyncOwner(input);
|
||||
if (selected.owner == "customisation" && !input.customisationReady) {
|
||||
return { ...selected, owner: "none", reason: "customisation-not-ready" };
|
||||
}
|
||||
if (selected.owner == "hidden-file" && !input.hiddenFileReady) {
|
||||
return { ...selected, owner: "none", reason: "hidden-file-not-ready" };
|
||||
}
|
||||
if (selected.owner == "hidden-file" && !input.hiddenFileEligible) {
|
||||
return { ...selected, owner: "none", reason: "hidden-file-filtered" };
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/** Decide whether a local scan may mutate an existing Customisation Sync document. */
|
||||
export function isCustomisationSyncDocumentLocallyOwned(input: CustomisationSyncDocumentOwnershipInput): boolean {
|
||||
if (!input.customisationEnabled) return false;
|
||||
const settingKey = getCustomisationSyncSettingKeyFromDocumentPath(input.documentPath);
|
||||
if (!settingKey) return false;
|
||||
const mode = input.pluginSyncExtendedSetting[settingKey]?.mode ?? MODE_SELECTIVE;
|
||||
return mode == MODE_SELECTIVE || mode == MODE_SHINY;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODE_AUTOMATIC,
|
||||
MODE_PAUSED,
|
||||
MODE_SELECTIVE,
|
||||
MODE_SHINY,
|
||||
type PluginSyncSettingEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import { isCustomisationSyncDocumentLocallyOwned, routeOptionalFileSyncPath } from "./optionalFileSyncRouting.ts";
|
||||
|
||||
const PATH = ".obsidian/plugins/example/data.json";
|
||||
|
||||
function route(
|
||||
mode: PluginSyncSettingEntry["mode"] | undefined,
|
||||
overrides: Partial<Parameters<typeof routeOptionalFileSyncPath>[0]> = {}
|
||||
) {
|
||||
const pluginSyncExtendedSetting: Record<string, PluginSyncSettingEntry> =
|
||||
mode === undefined
|
||||
? {}
|
||||
: {
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode,
|
||||
files: [],
|
||||
},
|
||||
};
|
||||
return routeOptionalFileSyncPath({
|
||||
path: PATH,
|
||||
configDir: ".obsidian",
|
||||
useV2: true,
|
||||
usePluginEtc: true,
|
||||
customisationEnabled: true,
|
||||
customisationReady: true,
|
||||
hiddenFileEnabled: true,
|
||||
hiddenFileReady: true,
|
||||
hiddenFileEligible: true,
|
||||
pluginSyncExtendedSetting,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("optional-file local-path routing policy", () => {
|
||||
it.each([
|
||||
["default Selective", undefined, "customisation", "customisation-selective"],
|
||||
["persisted Selective", MODE_SELECTIVE, "customisation", "customisation-selective"],
|
||||
["Flagged Selective", MODE_SHINY, "customisation", "customisation-flagged-selective"],
|
||||
["Automatic", MODE_AUTOMATIC, "hidden-file", "hidden-file-automatic"],
|
||||
["Ignore", MODE_PAUSED, "none", "customisation-paused"],
|
||||
] as const)("assigns %s to one owner", (_label, mode, owner, reason) => {
|
||||
expect(route(mode)).toMatchObject({
|
||||
owner,
|
||||
reason,
|
||||
category: "PLUGIN_DATA",
|
||||
settingKey: "PLUGIN_DATA/example",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the persisted setting key even when its file list is empty", () => {
|
||||
expect(route(MODE_AUTOMATIC)).toMatchObject({
|
||||
owner: "hidden-file",
|
||||
settingKey: "PLUGIN_DATA/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply Hidden File Sync filters to a Customisation Sync owner", () => {
|
||||
expect(route(MODE_SELECTIVE, { hiddenFileEligible: false })).toMatchObject({
|
||||
owner: "customisation",
|
||||
reason: "customisation-selective",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies readiness and Hidden File Sync eligibility after selecting the owner", () => {
|
||||
expect(route(MODE_SELECTIVE, { customisationReady: false })).toMatchObject({
|
||||
owner: "none",
|
||||
reason: "customisation-not-ready",
|
||||
});
|
||||
expect(route(MODE_AUTOMATIC, { hiddenFileReady: false })).toMatchObject({
|
||||
owner: "none",
|
||||
reason: "hidden-file-not-ready",
|
||||
});
|
||||
expect(route(MODE_AUTOMATIC, { hiddenFileEligible: false })).toMatchObject({
|
||||
owner: "none",
|
||||
reason: "hidden-file-filtered",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall back to Customisation Sync when Automatic mode has no Hidden File Sync owner", () => {
|
||||
expect(route(MODE_AUTOMATIC, { hiddenFileEnabled: false })).toMatchObject({
|
||||
owner: "none",
|
||||
reason: "hidden-file-disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes recognised files to Hidden File Sync when Customisation Sync is disabled", () => {
|
||||
expect(route(undefined, { customisationEnabled: false })).toMatchObject({
|
||||
owner: "hidden-file",
|
||||
reason: "hidden-file-path",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes other eligible hidden paths only to Hidden File Sync", () => {
|
||||
expect(route(undefined, { path: ".obsidian/workspace" })).toMatchObject({
|
||||
owner: "hidden-file",
|
||||
reason: "hidden-file-path",
|
||||
category: "",
|
||||
});
|
||||
expect(route(undefined, { path: ".trash/workspace.json" })).toMatchObject({
|
||||
owner: "none",
|
||||
reason: "unsupported-path",
|
||||
});
|
||||
expect(route(undefined, { path: "notes/workspace.json" })).toMatchObject({
|
||||
owner: "none",
|
||||
reason: "unsupported-path",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no owner when both optional-file features are disabled", () => {
|
||||
expect(
|
||||
route(undefined, {
|
||||
customisationEnabled: false,
|
||||
hiddenFileEnabled: false,
|
||||
})
|
||||
).toMatchObject({ owner: "none", reason: "features-disabled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Customisation Sync scan-document ownership", () => {
|
||||
it.each([
|
||||
["default Selective", undefined, true],
|
||||
["persisted Selective", MODE_SELECTIVE, true],
|
||||
["Flagged Selective", MODE_SHINY, true],
|
||||
["Automatic", MODE_AUTOMATIC, false],
|
||||
["Ignore", MODE_PAUSED, false],
|
||||
] as const)("allows mutation for %s=%s", (_label, mode, expected) => {
|
||||
const pluginSyncExtendedSetting: Record<string, PluginSyncSettingEntry> =
|
||||
mode === undefined
|
||||
? {}
|
||||
: {
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode,
|
||||
files: [],
|
||||
},
|
||||
};
|
||||
expect(
|
||||
isCustomisationSyncDocumentLocallyOwned({
|
||||
documentPath: "ix:device-a/PLUGIN_DATA/example.md" as never,
|
||||
customisationEnabled: true,
|
||||
pluginSyncExtendedSetting,
|
||||
})
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("disallows scan mutation while Customisation Sync is disabled", () => {
|
||||
expect(
|
||||
isCustomisationSyncDocumentLocallyOwned({
|
||||
documentPath: "ix:device-a/PLUGIN_DATA/example.md" as never,
|
||||
customisationEnabled: false,
|
||||
pluginSyncExtendedSetting: {},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { App } from "@/deps.ts";
|
||||
import { addIcon } from "@/deps.ts";
|
||||
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type {
|
||||
CustomisationSyncDialogView,
|
||||
CustomisationSyncUIControl,
|
||||
} from "@/features/ConfigSync/customisationSyncView.ts";
|
||||
import type { HiddenFileSyncInitialisationView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
|
||||
import { PluginDialogModal } from "@/features/ConfigSync/PluginDialogModal.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
/** Services needed to compose the Obsidian-owned Customisation Sync UI. */
|
||||
export type CustomisationSyncUIHost = NecessaryServices<"API" | "appLifecycle", never>;
|
||||
|
||||
/** The small modal surface owned by this composition feature. */
|
||||
export interface CustomisationSyncDialog {
|
||||
open(): void;
|
||||
close(): void;
|
||||
isOpened?(): boolean;
|
||||
isOpen?(): boolean;
|
||||
}
|
||||
|
||||
/** Replaceable modal construction seam used by focused interaction tests. */
|
||||
export type CustomisationSyncDialogFactory = (
|
||||
app: App,
|
||||
customisationSync: CustomisationSyncDialogView,
|
||||
hiddenFileSync: HiddenFileSyncInitialisationView
|
||||
) => CustomisationSyncDialog;
|
||||
|
||||
type RibbonElement = {
|
||||
addClass?: (name: string) => unknown;
|
||||
remove?: () => unknown;
|
||||
};
|
||||
|
||||
const CUSTOM_SYNC_ICON = `<g transform="rotate(-90 75 218)" fill="currentColor" fill-rule="evenodd">
|
||||
<path d="m272 166-9.38 9.38 9.38 9.38 9.38-9.38c1.96-1.93 5.11-1.9 7.03 0.058 1.91 1.94 1.91 5.04 0 6.98l-9.38 9.38 5.86 5.86-11.7 11.7c-8.34 8.35-21.4 9.68-31.3 3.19l-3.84 3.98c-8.45 8.7-20.1 13.6-32.2 13.6h-5.55v-9.95h5.55c9.43-0.0182 18.5-3.84 25-10.6l3.95-4.09c-6.54-9.86-5.23-23 3.14-31.3l11.7-11.7 5.86 5.86 9.38-9.38c1.96-1.93 5.11-1.9 7.03 0.0564 1.91 1.93 1.91 5.04 2e-3 6.98z"/>
|
||||
</g>`;
|
||||
|
||||
function defaultDialogFactory(
|
||||
app: App,
|
||||
customisationSync: CustomisationSyncDialogView,
|
||||
hiddenFileSync: HiddenFileSyncInitialisationView
|
||||
): CustomisationSyncDialog {
|
||||
return new PluginDialogModal(app, customisationSync, hiddenFileSync);
|
||||
}
|
||||
|
||||
function readDialogOpenState(dialog: CustomisationSyncDialog, fallback: boolean): boolean {
|
||||
const isOpened = dialog.isOpened?.();
|
||||
if (typeof isOpened === "boolean") return isOpened;
|
||||
const isOpen = dialog.isOpen?.();
|
||||
if (typeof isOpen === "boolean") return isOpen;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the Obsidian-owned Customisation Sync command, ribbon, and modal.
|
||||
*
|
||||
* Registration happens from `onLoaded`: the legacy add-on registered its UI
|
||||
* from `onload`, which is invoked after that lifecycle phase. Settings have
|
||||
* already been loaded by `onLoaded`, while command availability is still
|
||||
* evaluated lazily from the focused view.
|
||||
*/
|
||||
export function useCustomisationSyncUI(
|
||||
host: CustomisationSyncUIHost,
|
||||
app: App,
|
||||
customisationSync: CustomisationSyncDialogView,
|
||||
hiddenFileSync: HiddenFileSyncInitialisationView,
|
||||
createDialog: CustomisationSyncDialogFactory = defaultDialogFactory
|
||||
): CustomisationSyncUIControl {
|
||||
const { API, appLifecycle } = host.services;
|
||||
|
||||
let dialog: CustomisationSyncDialog | undefined;
|
||||
let dialogOpened = false;
|
||||
let ribbonElement: RibbonElement | undefined;
|
||||
let requestOpenDisposer: (() => void) | undefined;
|
||||
let loadedDisposer: (() => void) | undefined;
|
||||
let resumedDisposer: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
let registered = false;
|
||||
|
||||
const open = () => {
|
||||
if (disposed || !customisationSync.isEnabled()) return;
|
||||
if (!dialog) {
|
||||
dialog = createDialog(app, customisationSync, hiddenFileSync);
|
||||
}
|
||||
dialog.open();
|
||||
dialogOpened = true;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
const currentDialog = dialog;
|
||||
dialog = undefined;
|
||||
dialogOpened = false;
|
||||
currentDialog?.close();
|
||||
};
|
||||
|
||||
const isOpen = () => {
|
||||
if (!dialog) return false;
|
||||
return readDialogOpenState(dialog, dialogOpened);
|
||||
};
|
||||
|
||||
const updateRibbonVisibility = () => {
|
||||
if (typeof activeDocument === "undefined") return true;
|
||||
const element = activeDocument.querySelector<HTMLElement>(".livesync-ribbon-showcustom");
|
||||
element?.toggleClass("sls-hidden", !customisationSync.isEnabled());
|
||||
return true;
|
||||
};
|
||||
|
||||
const registerUI = () => {
|
||||
if (disposed || registered) return Promise.resolve(true);
|
||||
registered = true;
|
||||
|
||||
addIcon("custom-sync", CUSTOM_SYNC_ICON);
|
||||
API.addCommand({
|
||||
id: "livesync-plugin-dialog-ex",
|
||||
name: "Show customization sync dialog",
|
||||
checkCallback: (checking) => {
|
||||
if (!customisationSync.isEnabled()) return false;
|
||||
if (!checking) open();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
ribbonElement = API.addRibbonIcon("custom-sync", $msg("cmdConfigSync.showCustomizationSync"), () => open());
|
||||
ribbonElement?.addClass?.("livesync-ribbon-showcustom");
|
||||
requestOpenDisposer = eventHub.onEvent(EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, () => open());
|
||||
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
loadedDisposer = appLifecycle.onLoaded.addHandler(registerUI);
|
||||
resumedDisposer = appLifecycle.onResumed.addHandler(() => Promise.resolve(updateRibbonVisibility()));
|
||||
appLifecycle.onUnload.addHandler(() => {
|
||||
disposed = true;
|
||||
close();
|
||||
requestOpenDisposer?.();
|
||||
requestOpenDisposer = undefined;
|
||||
ribbonElement?.remove?.();
|
||||
ribbonElement = undefined;
|
||||
loadedDisposer?.();
|
||||
loadedDisposer = undefined;
|
||||
resumedDisposer?.();
|
||||
resumedDisposer = undefined;
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
return Object.freeze({ open, close, isOpen });
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "request-open-plugin-sync-dialog",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((key: string) => key),
|
||||
}));
|
||||
vi.mock("@/features/ConfigSync/PluginDialogModal.ts", () => ({
|
||||
PluginDialogModal: class PluginDialogModal {},
|
||||
}));
|
||||
|
||||
import { addIcon } from "@/deps.ts";
|
||||
import { eventHub } from "@/common/events.ts";
|
||||
import {
|
||||
type CustomisationSyncDialog,
|
||||
type CustomisationSyncDialogFactory,
|
||||
useCustomisationSyncUI,
|
||||
} from "./useCustomisationSyncUI.ts";
|
||||
|
||||
type Handler = () => unknown;
|
||||
|
||||
function createFixture(enabled = true) {
|
||||
let settingEnabled = enabled;
|
||||
let loadedHandler: Handler | undefined;
|
||||
let resumedHandler: Handler | undefined;
|
||||
let unloadHandler: Handler | undefined;
|
||||
|
||||
const loadedDisposer = vi.fn();
|
||||
const resumedDisposer = vi.fn();
|
||||
const eventDisposer = vi.fn();
|
||||
const ribbonElement = {
|
||||
addClass: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
}> = [];
|
||||
const ribbonCallbacks: Array<() => unknown> = [];
|
||||
|
||||
const host = {
|
||||
services: {
|
||||
API: {
|
||||
addCommand: vi.fn((command) => {
|
||||
commands.push(command);
|
||||
return command;
|
||||
}),
|
||||
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => unknown) => {
|
||||
ribbonCallbacks.push(callback);
|
||||
return ribbonElement;
|
||||
}),
|
||||
},
|
||||
appLifecycle: {
|
||||
onLoaded: {
|
||||
addHandler: vi.fn((handler: Handler) => {
|
||||
loadedHandler = handler;
|
||||
return loadedDisposer;
|
||||
}),
|
||||
},
|
||||
onResumed: {
|
||||
addHandler: vi.fn((handler: Handler) => {
|
||||
resumedHandler = handler;
|
||||
return resumedDisposer;
|
||||
}),
|
||||
},
|
||||
onUnload: {
|
||||
addHandler: vi.fn((handler: Handler) => {
|
||||
unloadHandler = handler;
|
||||
return vi.fn();
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
const customisationSync = {
|
||||
isEnabled: vi.fn(() => settingEnabled),
|
||||
} as any;
|
||||
const hiddenFileSync = {} as any;
|
||||
const modal: CustomisationSyncDialog = {
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
isOpened: vi.fn(() => true),
|
||||
};
|
||||
const createDialog = vi.fn<CustomisationSyncDialogFactory>(() => modal);
|
||||
|
||||
return {
|
||||
host,
|
||||
customisationSync,
|
||||
hiddenFileSync,
|
||||
modal,
|
||||
createDialog,
|
||||
commands,
|
||||
ribbonCallbacks,
|
||||
ribbonElement,
|
||||
eventDisposer,
|
||||
loadedDisposer,
|
||||
resumedDisposer,
|
||||
setEnabled(value: boolean) {
|
||||
settingEnabled = value;
|
||||
},
|
||||
get loadedHandler() {
|
||||
return loadedHandler;
|
||||
},
|
||||
get resumedHandler() {
|
||||
return resumedHandler;
|
||||
},
|
||||
get unloadHandler() {
|
||||
return unloadHandler;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useCustomisationSyncUI", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete (globalThis as { activeDocument?: unknown }).activeDocument;
|
||||
currentEventDisposer = vi.fn();
|
||||
vi.mocked(eventHub.onEvent).mockImplementation((_event, callback) => {
|
||||
fixtureEventCallback = callback as Handler;
|
||||
return currentEventDisposer;
|
||||
});
|
||||
});
|
||||
|
||||
let fixtureEventCallback: Handler | undefined;
|
||||
let currentEventDisposer = vi.fn();
|
||||
|
||||
it("registers the command and gates it on the focused view's enabled state", async () => {
|
||||
const fixture = createFixture(false);
|
||||
const control = useCustomisationSyncUI(
|
||||
fixture.host,
|
||||
{} as any,
|
||||
fixture.customisationSync,
|
||||
fixture.hiddenFileSync,
|
||||
fixture.createDialog
|
||||
);
|
||||
|
||||
expect(addIcon).not.toHaveBeenCalled();
|
||||
await fixture.loadedHandler?.();
|
||||
expect(addIcon).toHaveBeenCalledWith("custom-sync", expect.stringContaining("rotate(-90 75 218)"));
|
||||
|
||||
const command = fixture.commands.find(({ id }) => id === "livesync-plugin-dialog-ex");
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
expect(command?.checkCallback?.(false)).toBe(false);
|
||||
expect(fixture.createDialog).not.toHaveBeenCalled();
|
||||
|
||||
fixture.setEnabled(true);
|
||||
expect(command?.checkCallback?.(true)).toBe(true);
|
||||
expect(command?.checkCallback?.(false)).toBe(true);
|
||||
expect(fixture.createDialog).toHaveBeenCalledOnce();
|
||||
expect(control.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("opens from the request event and ribbon, reusing one modal instance", async () => {
|
||||
const fixture = createFixture(true);
|
||||
const control = useCustomisationSyncUI(
|
||||
fixture.host,
|
||||
{} as any,
|
||||
fixture.customisationSync,
|
||||
fixture.hiddenFileSync,
|
||||
fixture.createDialog
|
||||
);
|
||||
await fixture.loadedHandler?.();
|
||||
|
||||
expect(fixture.ribbonElement.addClass).toHaveBeenCalledWith("livesync-ribbon-showcustom");
|
||||
expect(fixture.host.services.API.addRibbonIcon).toHaveBeenCalledWith(
|
||||
"custom-sync",
|
||||
"cmdConfigSync.showCustomizationSync",
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(fixture.ribbonCallbacks).toHaveLength(1);
|
||||
expect(fixtureEventCallback).toBeDefined();
|
||||
|
||||
fixtureEventCallback?.();
|
||||
fixture.ribbonCallbacks[0]?.();
|
||||
|
||||
expect(fixture.createDialog).toHaveBeenCalledOnce();
|
||||
expect(fixture.modal.open).toHaveBeenCalledTimes(2);
|
||||
expect(control.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("updates resumed ribbon visibility and releases the event, ribbon, and modal on unload", async () => {
|
||||
const ribbonElement = {
|
||||
toggleClass: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(globalThis, "activeDocument", {
|
||||
configurable: true,
|
||||
value: {
|
||||
querySelector: vi.fn(() => ribbonElement),
|
||||
},
|
||||
});
|
||||
|
||||
const fixture = createFixture(true);
|
||||
currentEventDisposer = fixture.eventDisposer;
|
||||
const control = useCustomisationSyncUI(
|
||||
fixture.host,
|
||||
{} as any,
|
||||
fixture.customisationSync,
|
||||
fixture.hiddenFileSync,
|
||||
fixture.createDialog
|
||||
);
|
||||
await fixture.loadedHandler?.();
|
||||
await fixture.resumedHandler?.();
|
||||
expect(ribbonElement.toggleClass).toHaveBeenCalledWith("sls-hidden", false);
|
||||
|
||||
control.open();
|
||||
await fixture.unloadHandler?.();
|
||||
|
||||
expect(fixture.modal.close).toHaveBeenCalledOnce();
|
||||
expect(fixture.eventDisposer).toHaveBeenCalledOnce();
|
||||
expect(fixture.ribbonElement.remove).toHaveBeenCalledOnce();
|
||||
expect(fixture.loadedDisposer).toHaveBeenCalledOnce();
|
||||
expect(fixture.resumedDisposer).toHaveBeenCalledOnce();
|
||||
expect(control.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
|
||||
import type { HiddenFileSyncCommandView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
|
||||
export type HiddenFileSyncCommandHost = NecessaryServices<"API" | "appLifecycle", never>;
|
||||
|
||||
/** Register the Obsidian commands and event bridge for Hidden File Sync. */
|
||||
export function useHiddenFileSyncCommands(
|
||||
host: HiddenFileSyncCommandHost,
|
||||
operations: HiddenFileSyncCommandView
|
||||
): void {
|
||||
const { API, appLifecycle } = host.services;
|
||||
let registered = false;
|
||||
let disposed = false;
|
||||
let settingSavedDisposer: (() => void) | undefined;
|
||||
let loadedDisposer: (() => void) | undefined;
|
||||
let unloadDisposer: (() => void) | undefined;
|
||||
|
||||
const checkAndRun = (checking: boolean, operation: () => void) => {
|
||||
if (!operations.isManualCommandAvailable()) return false;
|
||||
if (!checking) operation();
|
||||
return true;
|
||||
};
|
||||
|
||||
const registerCommands = () => {
|
||||
if (registered || disposed) return Promise.resolve(true);
|
||||
registered = true;
|
||||
|
||||
API.addCommand({
|
||||
id: "livesync-sync-internal",
|
||||
name: "(re)initialise hidden files between storage and database",
|
||||
checkCallback: (checking) =>
|
||||
checkAndRun(checking, () => void operations.initialiseInternalFileSync("safe", true)),
|
||||
});
|
||||
API.addCommand({
|
||||
id: "livesync-scaninternal-storage",
|
||||
name: "Scan hidden file changes on the storage",
|
||||
checkCallback: (checking) => checkAndRun(checking, () => void operations.scanAllStorageChanges(true)),
|
||||
});
|
||||
API.addCommand({
|
||||
id: "livesync-scaninternal-database",
|
||||
name: "Scan hidden file changes on the local database",
|
||||
checkCallback: (checking) => checkAndRun(checking, () => void operations.scanAllDatabaseChanges(true)),
|
||||
});
|
||||
API.addCommand({
|
||||
id: "livesync-internal-scan-offline-changes",
|
||||
name: "Scan and apply all offline hidden-file changes",
|
||||
checkCallback: (checking) => checkAndRun(checking, () => void operations.applyOfflineChanges(true)),
|
||||
});
|
||||
settingSavedDisposer = eventHub.onEvent(EVENT_SETTING_SAVED, () => operations.updateSettingCache());
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
loadedDisposer = appLifecycle.onLoaded.addHandler(registerCommands);
|
||||
unloadDisposer = appLifecycle.onUnload.addHandler(() => {
|
||||
disposed = true;
|
||||
settingSavedDisposer?.();
|
||||
settingSavedDisposer = undefined;
|
||||
loadedDisposer?.();
|
||||
loadedDisposer = undefined;
|
||||
unloadDisposer?.();
|
||||
unloadDisposer = undefined;
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_SETTING_SAVED: "setting-saved",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(() => vi.fn()),
|
||||
},
|
||||
}));
|
||||
|
||||
import { eventHub } from "@/common/events.ts";
|
||||
import { useHiddenFileSyncCommands } from "./useHiddenFileSyncCommands.ts";
|
||||
|
||||
type Handler = () => Promise<boolean> | boolean;
|
||||
|
||||
function handlerRegistry() {
|
||||
const handlers: Handler[] = [];
|
||||
return {
|
||||
addHandler: vi.fn((handler: Handler) => {
|
||||
handlers.push(handler);
|
||||
return () => {
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index >= 0) handlers.splice(index, 1);
|
||||
};
|
||||
}),
|
||||
handlers,
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
}> = [];
|
||||
const onLoaded = handlerRegistry();
|
||||
const onUnload = handlerRegistry();
|
||||
const operations = {
|
||||
isManualCommandAvailable: vi.fn(() => true),
|
||||
initialiseInternalFileSync: vi.fn(async () => undefined),
|
||||
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||
applyOfflineChanges: vi.fn(async () => undefined),
|
||||
updateSettingCache: vi.fn(),
|
||||
};
|
||||
const host = {
|
||||
services: {
|
||||
API: {
|
||||
addCommand: vi.fn((command) => commands.push(command)),
|
||||
},
|
||||
appLifecycle: { onLoaded, onUnload },
|
||||
},
|
||||
};
|
||||
|
||||
useHiddenFileSyncCommands(host as never, operations);
|
||||
return { commands, host, onLoaded, onUnload, operations };
|
||||
}
|
||||
|
||||
describe("useHiddenFileSyncCommands", () => {
|
||||
it("registers the established commands after loading and delegates their actions", async () => {
|
||||
const { commands, onLoaded, operations } = createFixture();
|
||||
|
||||
await onLoaded.handlers[0]!();
|
||||
expect(commands.map(({ id }) => id)).toEqual([
|
||||
"livesync-sync-internal",
|
||||
"livesync-scaninternal-storage",
|
||||
"livesync-scaninternal-database",
|
||||
"livesync-internal-scan-offline-changes",
|
||||
]);
|
||||
|
||||
for (const command of commands) {
|
||||
expect(command.checkCallback?.(true)).toBe(true);
|
||||
command.checkCallback?.(false);
|
||||
}
|
||||
expect(operations.initialiseInternalFileSync).toHaveBeenCalledWith("safe", true);
|
||||
expect(operations.scanAllStorageChanges).toHaveBeenCalledWith(true);
|
||||
expect(operations.scanAllDatabaseChanges).toHaveBeenCalledWith(true);
|
||||
expect(operations.applyOfflineChanges).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("short-circuits every command through the operation view", async () => {
|
||||
const { commands, onLoaded, operations } = createFixture();
|
||||
operations.isManualCommandAvailable.mockReturnValue(false);
|
||||
|
||||
await onLoaded.handlers[0]!();
|
||||
|
||||
for (const command of commands) {
|
||||
expect(command.checkCallback?.(true)).toBe(false);
|
||||
expect(command.checkCallback?.(false)).toBe(false);
|
||||
}
|
||||
expect(operations.initialiseInternalFileSync).not.toHaveBeenCalled();
|
||||
expect(operations.scanAllStorageChanges).not.toHaveBeenCalled();
|
||||
expect(operations.scanAllDatabaseChanges).not.toHaveBeenCalled();
|
||||
expect(operations.applyOfflineChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("owns and releases the settings event subscription", async () => {
|
||||
vi.mocked(eventHub.onEvent).mockClear();
|
||||
const { onLoaded, onUnload, operations } = createFixture();
|
||||
|
||||
await onLoaded.handlers[0]!();
|
||||
const [, listener] = vi.mocked(eventHub.onEvent).mock.calls[0]!;
|
||||
const eventDisposer = vi.mocked(eventHub.onEvent).mock.results[0]!.value;
|
||||
listener(undefined as never);
|
||||
expect(operations.updateSettingCache).toHaveBeenCalledOnce();
|
||||
|
||||
await onUnload.handlers[0]!();
|
||||
expect(eventDisposer).toHaveBeenCalledOnce();
|
||||
expect(onLoaded.handlers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { FilePath, FilePathWithPrefix, UXFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import {
|
||||
isCustomisationSyncMetadata,
|
||||
isInternalMetadata,
|
||||
isPluginMetadata,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
|
||||
import {
|
||||
CustomisationSyncContext,
|
||||
type CustomisationSyncContextDependencies,
|
||||
} from "@/features/ConfigSync/customisationSyncContext.ts";
|
||||
import type { CustomisationSyncDialogView } from "@/features/ConfigSync/customisationSyncView.ts";
|
||||
import { HiddenFileSyncContext } from "@/features/HiddenFileSync/hiddenFileSyncContext.ts";
|
||||
import type {
|
||||
HiddenFileSyncCommandView,
|
||||
HiddenFileSyncInitialisationView,
|
||||
HiddenFileSyncRepairView,
|
||||
} from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import {
|
||||
createCustomisationSyncObsidianDependencies,
|
||||
type CustomisationSyncObsidianPolicies,
|
||||
} from "./customisationSyncObsidianAdapter.ts";
|
||||
import {
|
||||
createHiddenFileSyncObsidianDependencies,
|
||||
type HiddenFileSyncObsidianPolicies,
|
||||
} from "./hiddenFileSyncObsidianAdapter.ts";
|
||||
import {
|
||||
isCustomisationSyncDocumentLocallyOwned,
|
||||
routeOptionalFileSyncPath,
|
||||
selectOptionalFileSyncOwner,
|
||||
type OptionalFileSyncOwnerSelectionInput,
|
||||
} from "./optionalFileSyncRouting.ts";
|
||||
|
||||
export interface OptionalFileSyncFeature {
|
||||
readonly customisationSync: CustomisationSyncDialogView;
|
||||
readonly hiddenFileSyncCommands: HiddenFileSyncCommandView;
|
||||
readonly hiddenFileSyncInitialisation: HiddenFileSyncInitialisationView;
|
||||
readonly hiddenFileSyncRepair: HiddenFileSyncRepairView;
|
||||
/** @internal Direct runtime access for the repository's real-Obsidian contract tests. */
|
||||
readonly testing: {
|
||||
readonly customisationSync: CustomisationSyncContext;
|
||||
readonly hiddenFileSync: HiddenFileSyncContext;
|
||||
};
|
||||
}
|
||||
|
||||
export type OptionalFileSyncDependencies = Pick<Partial<CustomisationSyncContextDependencies>, "getUIControl"> & {
|
||||
createCustomisationSync?: (policies: CustomisationSyncObsidianPolicies) => CustomisationSyncContext;
|
||||
createHiddenFileSync?: (policies: HiddenFileSyncObsidianPolicies) => HiddenFileSyncContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compose Customisation Sync and Hidden File Sync as one optional-file owner.
|
||||
*
|
||||
* The two runtimes intentionally remain separate because they have distinct
|
||||
* state and persistence rules. Their shared service handlers are registered
|
||||
* here rather than as a side effect of constructing two add-ons. A pure
|
||||
* policy selects one local writer before either runtime callback is invoked.
|
||||
*/
|
||||
export function useOptionalFileSync(
|
||||
host: LiveSyncCore,
|
||||
dependencies: OptionalFileSyncDependencies = {}
|
||||
): OptionalFileSyncFeature {
|
||||
const createCustomisationSync =
|
||||
dependencies.createCustomisationSync ??
|
||||
((policies: CustomisationSyncObsidianPolicies) =>
|
||||
new CustomisationSyncContext(createCustomisationSyncObsidianDependencies(host, policies)));
|
||||
const createHiddenFileSync =
|
||||
dependencies.createHiddenFileSync ??
|
||||
((policies: HiddenFileSyncObsidianPolicies) =>
|
||||
new HiddenFileSyncContext(createHiddenFileSyncObsidianDependencies(host, policies)));
|
||||
|
||||
const ownerSelectionInput = (path: FilePath): OptionalFileSyncOwnerSelectionInput => ({
|
||||
path,
|
||||
configDir: host.services.API.getSystemConfigDir(),
|
||||
useV2: host.settings.usePluginSyncV2,
|
||||
usePluginEtc: host.settings.usePluginEtc,
|
||||
customisationEnabled: host.settings.usePluginSync,
|
||||
hiddenFileEnabled: host.settings.syncInternalFiles,
|
||||
pluginSyncExtendedSetting: host.settings.pluginSyncExtendedSetting,
|
||||
});
|
||||
const ownsLocalFile = (owner: "customisation" | "hidden-file") => (path: FilePath) =>
|
||||
selectOptionalFileSyncOwner(ownerSelectionInput(path)).owner == owner;
|
||||
|
||||
const customisationSync = createCustomisationSync({
|
||||
getUIControl: () => dependencies.getUIControl?.(),
|
||||
ownsLocalFile: ownsLocalFile("customisation"),
|
||||
ownsLocalDocument: (documentPath) =>
|
||||
isCustomisationSyncDocumentLocallyOwned({
|
||||
documentPath,
|
||||
customisationEnabled: host.settings.usePluginSync,
|
||||
pluginSyncExtendedSetting: host.settings.pluginSyncExtendedSetting,
|
||||
}),
|
||||
});
|
||||
const hiddenFileSync = createHiddenFileSync({
|
||||
ownsLocalFile: ownsLocalFile("hidden-file"),
|
||||
});
|
||||
const { services } = host;
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
const register = (dispose: () => void) => {
|
||||
disposers.push(dispose);
|
||||
};
|
||||
|
||||
const normaliseLocalPath = (file: string | UXFileInfoStub) =>
|
||||
stripAllPrefixes((typeof file === "string" ? file : file.path) as FilePathWithPrefix);
|
||||
const routeLocalPath = async (path: FilePath) => {
|
||||
const selected = selectOptionalFileSyncOwner(ownerSelectionInput(path));
|
||||
const hiddenFileEligible =
|
||||
selected.owner == "hidden-file" ? await hiddenFileSync.isTargetFileEligible(path) : false;
|
||||
const ready = services.appLifecycle.isReady() && !services.appLifecycle.isSuspended();
|
||||
return routeOptionalFileSyncPath({
|
||||
...ownerSelectionInput(path),
|
||||
customisationReady: ready,
|
||||
hiddenFileReady: ready,
|
||||
hiddenFileEligible,
|
||||
});
|
||||
};
|
||||
|
||||
register(
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(async (path: FilePath) => {
|
||||
const localPath = normaliseLocalPath(path);
|
||||
const decision = await routeLocalPath(localPath);
|
||||
if (decision.owner == "customisation") {
|
||||
return await customisationSync._anyProcessOptionalFileEvent(localPath);
|
||||
}
|
||||
if (decision.owner == "hidden-file") {
|
||||
return await hiddenFileSync._anyProcessOptionalFileEvent(localPath);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
register(
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(async (path: FilePathWithPrefix) => {
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
return await customisationSync._anyGetOptionalConflictCheckMethod(path);
|
||||
}
|
||||
if (isInternalMetadata(path)) {
|
||||
return await hiddenFileSync._anyGetOptionalConflictCheckMethod(path);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
register(
|
||||
services.replication.processVirtualDocument.addHandler(
|
||||
customisationSync._anyModuleParsedReplicationResultItem.bind(customisationSync)
|
||||
)
|
||||
);
|
||||
register(
|
||||
services.replication.processOptionalSynchroniseResult.addHandler(
|
||||
hiddenFileSync._anyProcessOptionalSyncFiles.bind(hiddenFileSync)
|
||||
)
|
||||
);
|
||||
register(
|
||||
services.appLifecycle.onSettingLoaded.addHandler(
|
||||
hiddenFileSync._everyOnloadAfterLoadSettings.bind(hiddenFileSync)
|
||||
)
|
||||
);
|
||||
|
||||
register(
|
||||
services.setting.onRealiseSetting.addHandler(
|
||||
customisationSync._everyRealizeSettingSyncMode.bind(customisationSync)
|
||||
)
|
||||
);
|
||||
register(
|
||||
services.setting.onRealiseSetting.addHandler(hiddenFileSync._everyRealizeSettingSyncMode.bind(hiddenFileSync))
|
||||
);
|
||||
register(
|
||||
services.appLifecycle.onResuming.addHandler(customisationSync._everyOnResumeProcess.bind(customisationSync))
|
||||
);
|
||||
register(services.appLifecycle.onResuming.addHandler(hiddenFileSync._everyOnResumeProcess.bind(hiddenFileSync)));
|
||||
register(
|
||||
services.replication.onBeforeReplicate.addHandler(
|
||||
customisationSync._everyBeforeReplicate.bind(customisationSync)
|
||||
)
|
||||
);
|
||||
register(
|
||||
services.replication.onBeforeReplicate.addHandler(hiddenFileSync._everyBeforeReplicate.bind(hiddenFileSync))
|
||||
);
|
||||
register(
|
||||
services.databaseEvents.onDatabaseInitialised.addHandler(
|
||||
customisationSync._everyOnDatabaseInitialized.bind(customisationSync)
|
||||
)
|
||||
);
|
||||
register(
|
||||
services.databaseEvents.onDatabaseInitialised.addHandler(
|
||||
hiddenFileSync._everyOnDatabaseInitialized.bind(hiddenFileSync)
|
||||
)
|
||||
);
|
||||
register(
|
||||
services.setting.suspendExtraSync.addHandler(customisationSync._allSuspendExtraSync.bind(customisationSync))
|
||||
);
|
||||
register(services.setting.suspendExtraSync.addHandler(hiddenFileSync._allSuspendExtraSync.bind(hiddenFileSync)));
|
||||
register(
|
||||
services.setting.enableOptionalFeature.addHandler(
|
||||
customisationSync._allConfigureOptionalSyncFeature.bind(customisationSync)
|
||||
)
|
||||
);
|
||||
register(
|
||||
services.setting.enableOptionalFeature.addHandler(
|
||||
hiddenFileSync._allConfigureOptionalSyncFeature.bind(hiddenFileSync)
|
||||
)
|
||||
);
|
||||
register(
|
||||
services.vault.isTargetFileInExtra.addHandler(
|
||||
async (file: string | UXFileInfoStub) => (await routeLocalPath(normaliseLocalPath(file))).owner != "none"
|
||||
)
|
||||
);
|
||||
|
||||
register(
|
||||
services.appLifecycle.onUnload.addHandler(async () => {
|
||||
let succeeded = true;
|
||||
for (const dispose of disposers.splice(0)) {
|
||||
try {
|
||||
dispose();
|
||||
} catch {
|
||||
succeeded = false;
|
||||
}
|
||||
}
|
||||
for (const context of [customisationSync, hiddenFileSync]) {
|
||||
try {
|
||||
await Promise.resolve(context.dispose());
|
||||
} catch {
|
||||
succeeded = false;
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
})
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
customisationSync,
|
||||
hiddenFileSyncCommands: hiddenFileSync,
|
||||
hiddenFileSyncInitialisation: hiddenFileSync,
|
||||
hiddenFileSyncRepair: hiddenFileSync,
|
||||
testing: Object.freeze({ customisationSync, hiddenFileSync }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
allSettledFunction,
|
||||
anySuccessFunction,
|
||||
bailFirstFailureFunction,
|
||||
firstResultFunction,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import { MODE_AUTOMATIC, MODE_PAUSED } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/features/ConfigSync/customisationSyncContext.ts", () => ({
|
||||
CustomisationSyncContext: class CustomisationSyncContext {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileSync/hiddenFileSyncContext.ts", () => ({
|
||||
HiddenFileSyncContext: class HiddenFileSyncContext {},
|
||||
}));
|
||||
vi.mock("./customisationSyncObsidianAdapter.ts", () => ({
|
||||
createCustomisationSyncObsidianDependencies: vi.fn(),
|
||||
}));
|
||||
vi.mock("./hiddenFileSyncObsidianAdapter.ts", () => ({
|
||||
createHiddenFileSyncObsidianDependencies: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useOptionalFileSync } from "./useOptionalFileSync.ts";
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
function handlerRegistry() {
|
||||
const handlers: Handler[] = [];
|
||||
return {
|
||||
addHandler: vi.fn((handler: Handler) => {
|
||||
handlers.push(handler);
|
||||
return () => {
|
||||
const index = handlers.indexOf(handler);
|
||||
if (index >= 0) handlers.splice(index, 1);
|
||||
};
|
||||
}),
|
||||
handlers,
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const processOptionalFileEvent = handlerRegistry();
|
||||
const getOptionalConflictCheckMethod = handlerRegistry();
|
||||
const processVirtualDocument = handlerRegistry();
|
||||
const processOptionalSynchroniseResult = handlerRegistry();
|
||||
const onRealiseSetting = handlerRegistry();
|
||||
const onSettingLoaded = handlerRegistry();
|
||||
const onResuming = handlerRegistry();
|
||||
const onBeforeReplicate = handlerRegistry();
|
||||
const onDatabaseInitialised = handlerRegistry();
|
||||
const suspendExtraSync = handlerRegistry();
|
||||
const enableOptionalFeature = handlerRegistry();
|
||||
const isTargetFileInExtra = handlerRegistry();
|
||||
const onUnload = handlerRegistry();
|
||||
|
||||
const calls: string[] = [];
|
||||
const customisationSync = {
|
||||
dispose: vi.fn(() => calls.push("customisation:unload")),
|
||||
_anyProcessOptionalFileEvent: vi.fn(async () => false),
|
||||
_anyGetOptionalConflictCheckMethod: vi.fn(async () => false),
|
||||
_anyModuleParsedReplicationResultItem: vi.fn(async () => false),
|
||||
_everyRealizeSettingSyncMode: vi.fn(async () => true),
|
||||
_everyOnResumeProcess: vi.fn(async () => true),
|
||||
_everyBeforeReplicate: vi.fn(async () => true),
|
||||
_everyOnDatabaseInitialized: vi.fn(async () => true),
|
||||
_allSuspendExtraSync: vi.fn(async () => true),
|
||||
_allConfigureOptionalSyncFeature: vi.fn(async () => true),
|
||||
};
|
||||
const hiddenFileSync = {
|
||||
dispose: vi.fn(() => calls.push("hidden:unload")),
|
||||
_everyOnloadAfterLoadSettings: vi.fn(async () => true),
|
||||
_anyProcessOptionalFileEvent: vi.fn(async () => false),
|
||||
_anyGetOptionalConflictCheckMethod: vi.fn(async () => false),
|
||||
_anyProcessOptionalSyncFiles: vi.fn(async () => false),
|
||||
_everyRealizeSettingSyncMode: vi.fn(async () => true),
|
||||
_everyOnResumeProcess: vi.fn(async () => true),
|
||||
_everyBeforeReplicate: vi.fn(async () => true),
|
||||
_everyOnDatabaseInitialized: vi.fn(async () => true),
|
||||
_allSuspendExtraSync: vi.fn(async () => true),
|
||||
_allConfigureOptionalSyncFeature: vi.fn(async () => true),
|
||||
isTargetFileEligible: vi.fn(async () => true),
|
||||
};
|
||||
const settings = {
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
usePluginEtc: true,
|
||||
syncInternalFiles: true,
|
||||
pluginSyncExtendedSetting: {},
|
||||
};
|
||||
const services = {
|
||||
fileProcessing: { processOptionalFileEvent },
|
||||
conflict: { getOptionalConflictCheckMethod },
|
||||
replication: { processVirtualDocument, processOptionalSynchroniseResult, onBeforeReplicate },
|
||||
setting: { onRealiseSetting, suspendExtraSync, enableOptionalFeature },
|
||||
appLifecycle: {
|
||||
onSettingLoaded,
|
||||
onResuming,
|
||||
onUnload,
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
databaseEvents: { onDatabaseInitialised },
|
||||
vault: { isTargetFileInExtra },
|
||||
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
|
||||
};
|
||||
const host = { services, settings };
|
||||
|
||||
let customisationDependencies: unknown;
|
||||
let hiddenFileDependencies: unknown;
|
||||
|
||||
const feature = useOptionalFileSync(host as never, {
|
||||
createCustomisationSync: (contextDependencies) => {
|
||||
customisationDependencies = contextDependencies;
|
||||
return customisationSync as never;
|
||||
},
|
||||
createHiddenFileSync: (contextDependencies) => {
|
||||
hiddenFileDependencies = contextDependencies;
|
||||
return hiddenFileSync as never;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
calls,
|
||||
customisationSync,
|
||||
feature,
|
||||
hiddenFileSync,
|
||||
settings,
|
||||
contextDependencies: {
|
||||
customisation: () => customisationDependencies,
|
||||
hiddenFile: () => hiddenFileDependencies,
|
||||
},
|
||||
registries: {
|
||||
enableOptionalFeature,
|
||||
getOptionalConflictCheckMethod,
|
||||
isTargetFileInExtra,
|
||||
onBeforeReplicate,
|
||||
onDatabaseInitialised,
|
||||
onRealiseSetting,
|
||||
onResuming,
|
||||
onSettingLoaded,
|
||||
onUnload,
|
||||
processOptionalFileEvent,
|
||||
processOptionalSynchroniseResult,
|
||||
processVirtualDocument,
|
||||
suspendExtraSync,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAggregateFixture() {
|
||||
const customisationSync = {
|
||||
dispose: vi.fn(),
|
||||
_anyProcessOptionalFileEvent: vi.fn(async () => false),
|
||||
_anyGetOptionalConflictCheckMethod: vi.fn(async (): Promise<boolean | "newer"> => false),
|
||||
_anyModuleParsedReplicationResultItem: vi.fn(async () => false),
|
||||
_everyRealizeSettingSyncMode: vi.fn(async () => true),
|
||||
_everyOnResumeProcess: vi.fn(async () => true),
|
||||
_everyBeforeReplicate: vi.fn(async () => true),
|
||||
_everyOnDatabaseInitialized: vi.fn(async () => true),
|
||||
_allSuspendExtraSync: vi.fn(async () => true),
|
||||
_allConfigureOptionalSyncFeature: vi.fn(async () => true),
|
||||
};
|
||||
const hiddenFileSync = {
|
||||
dispose: vi.fn(),
|
||||
_everyOnloadAfterLoadSettings: vi.fn(async () => true),
|
||||
_anyProcessOptionalFileEvent: vi.fn(async () => false),
|
||||
_anyGetOptionalConflictCheckMethod: vi.fn(async (): Promise<boolean | "newer"> => false),
|
||||
_anyProcessOptionalSyncFiles: vi.fn(async () => false),
|
||||
_everyRealizeSettingSyncMode: vi.fn(async () => true),
|
||||
_everyOnResumeProcess: vi.fn(async () => true),
|
||||
_everyBeforeReplicate: vi.fn(async () => true),
|
||||
_everyOnDatabaseInitialized: vi.fn(async () => true),
|
||||
_allSuspendExtraSync: vi.fn(async () => true),
|
||||
_allConfigureOptionalSyncFeature: vi.fn(async () => true),
|
||||
isTargetFileEligible: vi.fn(async () => true),
|
||||
};
|
||||
const settings = {
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
usePluginEtc: true,
|
||||
syncInternalFiles: true,
|
||||
pluginSyncExtendedSetting: {},
|
||||
};
|
||||
const booleanAnySuccess = (name: string) => anySuccessFunction<(...args: any[]) => Promise<boolean>>(name);
|
||||
const booleanBail = (name: string) => bailFirstFailureFunction<(...args: any[]) => Promise<boolean>>(name);
|
||||
const services = {
|
||||
fileProcessing: { processOptionalFileEvent: booleanAnySuccess("processOptionalFileEvent") },
|
||||
conflict: {
|
||||
getOptionalConflictCheckMethod: firstResultFunction<(...args: any[]) => Promise<boolean | "newer">>(
|
||||
"getOptionalConflictCheckMethod"
|
||||
),
|
||||
},
|
||||
replication: {
|
||||
processVirtualDocument: booleanAnySuccess("processVirtualDocument"),
|
||||
processOptionalSynchroniseResult: booleanAnySuccess("processOptionalSynchroniseResult"),
|
||||
onBeforeReplicate: booleanBail("onBeforeReplicate"),
|
||||
},
|
||||
setting: {
|
||||
onRealiseSetting: booleanBail("onRealiseSetting"),
|
||||
suspendExtraSync: booleanBail("suspendExtraSync"),
|
||||
enableOptionalFeature: booleanBail("enableOptionalFeature"),
|
||||
},
|
||||
appLifecycle: {
|
||||
onSettingLoaded: booleanBail("onSettingLoaded"),
|
||||
onResuming: booleanBail("onResuming"),
|
||||
onUnload: allSettledFunction<() => Promise<boolean>>("onUnload"),
|
||||
isReady: vi.fn(() => true),
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
databaseEvents: { onDatabaseInitialised: booleanBail("onDatabaseInitialised") },
|
||||
vault: { isTargetFileInExtra: booleanAnySuccess("isTargetFileInExtra") },
|
||||
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
|
||||
};
|
||||
|
||||
useOptionalFileSync({ services, settings } as never, {
|
||||
createCustomisationSync: () => customisationSync as never,
|
||||
createHiddenFileSync: () => hiddenFileSync as never,
|
||||
});
|
||||
|
||||
return { customisationSync, hiddenFileSync, services, settings };
|
||||
}
|
||||
|
||||
describe("useOptionalFileSync", () => {
|
||||
it("registers one owner-selecting handler for each overlapping result contract", () => {
|
||||
const { registries } = createFixture();
|
||||
|
||||
expect(registries.processOptionalFileEvent.handlers).toHaveLength(1);
|
||||
expect(registries.getOptionalConflictCheckMethod.handlers).toHaveLength(1);
|
||||
expect(registries.onRealiseSetting.handlers).toHaveLength(2);
|
||||
expect(registries.onResuming.handlers).toHaveLength(2);
|
||||
expect(registries.onBeforeReplicate.handlers).toHaveLength(2);
|
||||
expect(registries.onDatabaseInitialised.handlers).toHaveLength(2);
|
||||
expect(registries.suspendExtraSync.handlers).toHaveLength(2);
|
||||
expect(registries.enableOptionalFeature.handlers).toHaveLength(2);
|
||||
expect(registries.processVirtualDocument.handlers).toHaveLength(1);
|
||||
expect(registries.processOptionalSynchroniseResult.handlers).toHaveLength(1);
|
||||
expect(registries.onSettingLoaded.handlers).toHaveLength(1);
|
||||
expect(registries.isTargetFileInExtra.handlers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("routes Selective and Automatic paths to exactly one local owner", async () => {
|
||||
const { customisationSync, hiddenFileSync, registries, settings } = createFixture();
|
||||
customisationSync._anyProcessOptionalFileEvent.mockResolvedValue(true);
|
||||
hiddenFileSync._anyProcessOptionalFileEvent.mockResolvedValue(true);
|
||||
hiddenFileSync.isTargetFileEligible.mockResolvedValue(false);
|
||||
|
||||
await expect(registries.isTargetFileInExtra.handlers[0]!(".obsidian/plugins/example/data.json")).resolves.toBe(
|
||||
true
|
||||
);
|
||||
await expect(
|
||||
registries.processOptionalFileEvent.handlers[0]!(".obsidian/plugins/example/data.json")
|
||||
).resolves.toBe(true);
|
||||
expect(customisationSync._anyProcessOptionalFileEvent).toHaveBeenCalledOnce();
|
||||
expect(hiddenFileSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.isTargetFileEligible).not.toHaveBeenCalled();
|
||||
|
||||
settings.pluginSyncExtendedSetting = {
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: [],
|
||||
},
|
||||
};
|
||||
hiddenFileSync.isTargetFileEligible.mockResolvedValue(true);
|
||||
customisationSync._anyProcessOptionalFileEvent.mockClear();
|
||||
|
||||
await expect(
|
||||
registries.processOptionalFileEvent.handlers[0]!(".obsidian/plugins/example/data.json")
|
||||
).resolves.toBe(true);
|
||||
expect(hiddenFileSync._anyProcessOptionalFileEvent).toHaveBeenCalledOnce();
|
||||
expect(customisationSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to the other context when the selected owner skips or fails", async () => {
|
||||
const { customisationSync, hiddenFileSync, services } = createAggregateFixture();
|
||||
customisationSync._anyProcessOptionalFileEvent.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(services.fileProcessing.processOptionalFileEvent(".obsidian/app.json")).resolves.toBe(false);
|
||||
expect(hiddenFileSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
|
||||
|
||||
customisationSync._anyProcessOptionalFileEvent.mockRejectedValueOnce(new Error("customisation failed"));
|
||||
await expect(services.fileProcessing.processOptionalFileEvent(".obsidian/app.json")).resolves.toBe(false);
|
||||
expect(hiddenFileSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches conflict documents by their persisted namespace", async () => {
|
||||
const { customisationSync, hiddenFileSync, services } = createAggregateFixture();
|
||||
customisationSync._anyGetOptionalConflictCheckMethod.mockResolvedValue("newer");
|
||||
hiddenFileSync._anyGetOptionalConflictCheckMethod.mockResolvedValue(true);
|
||||
|
||||
await expect(services.conflict.getOptionalConflictCheckMethod("ix:device/app.json")).resolves.toBe("newer");
|
||||
expect(hiddenFileSync._anyGetOptionalConflictCheckMethod).not.toHaveBeenCalled();
|
||||
await expect(services.conflict.getOptionalConflictCheckMethod("i:.obsidian/example.json")).resolves.toBe(true);
|
||||
expect(customisationSync._anyGetOptionalConflictCheckMethod).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps persisted document acceptance separate from current local ownership", async () => {
|
||||
const { customisationSync, hiddenFileSync, registries, settings } = createFixture();
|
||||
settings.usePluginSync = false;
|
||||
settings.syncInternalFiles = false;
|
||||
|
||||
await registries.processVirtualDocument.handlers[0]!({ _id: "ix:device-a/CONFIG/app.json.md" });
|
||||
await registries.processOptionalSynchroniseResult.handlers[0]!({ _id: "i:.obsidian/app.json" });
|
||||
|
||||
expect(customisationSync._anyModuleParsedReplicationResultItem).toHaveBeenCalledOnce();
|
||||
expect(hiddenFileSync._anyProcessOptionalSyncFiles).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("routes Ignore mode to neither local context", async () => {
|
||||
const { customisationSync, hiddenFileSync, registries, settings } = createFixture();
|
||||
settings.pluginSyncExtendedSetting = {
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode: MODE_PAUSED,
|
||||
files: ["plugins/example/data.json"],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
registries.processOptionalFileEvent.handlers[0]!(".obsidian/plugins/example/data.json")
|
||||
).resolves.toBe(false);
|
||||
expect(customisationSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync._anyProcessOptionalFileEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("injects the same static ownership policy into both scan contexts", () => {
|
||||
const { contextDependencies, settings } = createFixture();
|
||||
const customisation = contextDependencies.customisation() as {
|
||||
ownsLocalFile(path: string): boolean;
|
||||
ownsLocalDocument(path: string): boolean;
|
||||
};
|
||||
const hiddenFile = contextDependencies.hiddenFile() as {
|
||||
ownsLocalFile(path: string): boolean;
|
||||
};
|
||||
const path = ".obsidian/plugins/example/data.json";
|
||||
|
||||
expect(customisation.ownsLocalFile(path)).toBe(true);
|
||||
expect(hiddenFile.ownsLocalFile(path)).toBe(false);
|
||||
expect(customisation.ownsLocalDocument("ix:device-a/PLUGIN_DATA/example.md")).toBe(true);
|
||||
|
||||
settings.pluginSyncExtendedSetting = {
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: [],
|
||||
},
|
||||
};
|
||||
expect(customisation.ownsLocalFile(path)).toBe(false);
|
||||
expect(hiddenFile.ownsLocalFile(path)).toBe(true);
|
||||
expect(customisation.ownsLocalDocument("ix:device-a/PLUGIN_DATA/example.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves bail-first-failure and settled-unload behaviour", async () => {
|
||||
const { customisationSync, hiddenFileSync, services } = createAggregateFixture();
|
||||
customisationSync._everyRealizeSettingSyncMode.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(services.setting.onRealiseSetting()).resolves.toBe(false);
|
||||
expect(hiddenFileSync._everyRealizeSettingSyncMode).not.toHaveBeenCalled();
|
||||
|
||||
customisationSync.dispose.mockImplementationOnce(() => {
|
||||
throw new Error("customisation disposal failed");
|
||||
});
|
||||
await expect(services.appLifecycle.onUnload()).resolves.toBe(false);
|
||||
expect(hiddenFileSync.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disposes both contexts and their registrations through the application lifecycle", async () => {
|
||||
const { calls, customisationSync, hiddenFileSync, registries } = createFixture();
|
||||
customisationSync.dispose.mockImplementation(() =>
|
||||
calls.push(`customisation:unload:${registries.processOptionalFileEvent.handlers.length}`)
|
||||
);
|
||||
hiddenFileSync.dispose.mockImplementation(() =>
|
||||
calls.push(`hidden:unload:${registries.processOptionalFileEvent.handlers.length}`)
|
||||
);
|
||||
|
||||
await registries.onUnload.handlers[0]!();
|
||||
expect(calls).toEqual(["customisation:unload:0", "hidden:unload:0"]);
|
||||
expect(registries.processOptionalFileEvent.handlers).toHaveLength(0);
|
||||
expect(registries.getOptionalConflictCheckMethod.handlers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("strips a database prefix before evaluating a Hidden File Sync target", async () => {
|
||||
const { hiddenFileSync, registries } = createFixture();
|
||||
|
||||
await registries.isTargetFileInExtra.handlers[0]!({ path: "i:.obsidian/workspace" });
|
||||
|
||||
expect(hiddenFileSync.isTargetFileEligible).toHaveBeenCalledWith(".obsidian/workspace");
|
||||
});
|
||||
|
||||
it("returns focused views without registering either context as an add-on", () => {
|
||||
const { customisationSync, feature, hiddenFileSync } = createFixture();
|
||||
|
||||
expect(feature.customisationSync).toBe(customisationSync);
|
||||
expect(feature.hiddenFileSyncCommands).toBe(hiddenFileSync);
|
||||
expect(feature.hiddenFileSyncInitialisation).toBe(hiddenFileSync);
|
||||
expect(feature.hiddenFileSyncRepair).toBe(hiddenFileSync);
|
||||
expect(feature.testing).toEqual({ customisationSync, hiddenFileSync });
|
||||
expect(Object.isFrozen(feature.testing)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user