Merge main into shared runtime foundations

Keep SyncGuard startup dependencies isolated in unload logging tests.
This commit is contained in:
vorotamoroz committed 2026-10-30 15:36:57 +00:00
commit bd05dde39c
83 files changed
+6313 -1507

No files matched your search

+77 -43
View File
@@ -13,13 +13,25 @@ import {
export const FAST_FORWARD_COUNT_LINGER_MS = 3_000;
function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>): number | undefined {
const count = source.value.couchDB?.pull?.checkedChanges;
export type FastForwardCheckedChanges = {
push?: number;
pull?: number;
};
function readPositiveCheckedChanges(
source: ReactiveValue<ReplicationStatics>,
direction: keyof FastForwardCheckedChanges
): number | undefined {
const count = source.value.couchDB?.[direction]?.checkedChanges;
return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined;
}
function copyCheckedChanges(counts: FastForwardCheckedChanges): FastForwardCheckedChanges {
return { ...counts };
}
/**
* Briefly displays changing CouchDB pull-feed comparison counts.
* Briefly displays changing CouchDB push- and pull-feed comparison counts.
*
* Unrelated replication-stat changes do not extend the linger or redisplay an
* expired count. A positive initial snapshot is displayed to late subscribers.
@@ -27,46 +39,61 @@ function readPositiveCheckedChanges(source: ReactiveValue<ReplicationStatics>):
export function createFastForwardCheckedChangesDisplay(
source: ReactiveValue<ReplicationStatics>,
lingerMs = FAST_FORWARD_COUNT_LINGER_MS
): DisposableReactiveValue<number | undefined> {
const displayed = reactiveSource<number | undefined>(undefined);
): DisposableReactiveValue<FastForwardCheckedChanges> {
const displayed = reactiveSource<FastForwardCheckedChanges>({});
const linger = Math.max(0, lingerMs);
let lastObserved = readPositiveCheckedChanges(source);
let clearTimer: number | undefined;
const directions: (keyof FastForwardCheckedChanges)[] = ["push", "pull"];
const lastObserved: FastForwardCheckedChanges = {};
const clearTimers = new Map<keyof FastForwardCheckedChanges, number>();
let disposed = false;
const cancelClear = () => {
if (clearTimer === undefined) return;
compatGlobal.clearTimeout(clearTimer);
clearTimer = undefined;
const cancelClear = (direction: keyof FastForwardCheckedChanges) => {
const timer = clearTimers.get(direction);
if (timer === undefined) return;
compatGlobal.clearTimeout(timer);
clearTimers.delete(direction);
};
const scheduleClear = () => {
clearTimer = compatGlobal.setTimeout(() => {
clearTimer = undefined;
const scheduleClear = (direction: keyof FastForwardCheckedChanges) => {
const timer = compatGlobal.setTimeout(() => {
clearTimers.delete(direction);
if (disposed) return;
if (readPositiveCheckedChanges(source) !== lastObserved) {
if (readPositiveCheckedChanges(source, direction) !== lastObserved[direction]) {
update();
return;
}
displayed.value = undefined;
const nextDisplayed = copyCheckedChanges(displayed.value);
delete nextDisplayed[direction];
displayed.value = nextDisplayed;
}, linger);
clearTimers.set(direction, timer);
};
const update = () => {
if (disposed) return;
const nextCount = readPositiveCheckedChanges(source);
if (nextCount === lastObserved) return;
lastObserved = nextCount;
cancelClear();
if (nextCount === undefined) {
displayed.value = undefined;
return;
const nextDisplayed = copyCheckedChanges(displayed.value);
let changed = false;
for (const direction of directions) {
const nextCount = readPositiveCheckedChanges(source, direction);
if (nextCount === lastObserved[direction]) continue;
lastObserved[direction] = nextCount;
cancelClear(direction);
if (nextCount === undefined) {
delete nextDisplayed[direction];
changed = true;
continue;
}
nextDisplayed[direction] = nextCount;
scheduleClear(direction);
changed = true;
}
displayed.value = nextCount;
scheduleClear();
if (changed) displayed.value = nextDisplayed;
};
if (lastObserved !== undefined) {
displayed.value = lastObserved;
scheduleClear();
for (const direction of directions) {
const count = readPositiveCheckedChanges(source, direction);
if (count === undefined) continue;
lastObserved[direction] = count;
displayed.value = { ...displayed.value, [direction]: count };
scheduleClear(direction);
}
source.onChanged(update);
@@ -83,7 +110,7 @@ export function createFastForwardCheckedChangesDisplay(
dispose() {
if (disposed) return;
disposed = true;
cancelClear();
for (const direction of directions) cancelClear(direction);
source.offChanged(update);
},
};
@@ -113,7 +140,7 @@ function remainingSequenceLabel(
export function projectReplicationStatusLabel(
stats: ReplicationStatics,
networkStatus: RemoteActivityStatus,
fastForwardCount?: number
fastForwardCounts?: FastForwardCheckedChanges
): ReplicationStatusProjection {
const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats;
const networkActivity = formatRemoteActivityStatusLabel(networkStatus);
@@ -154,14 +181,19 @@ export function projectReplicationStatusLabel(
(couchDB.pull?.checking === true || couchDB.push?.checking === true);
if (isFastForwardChecking) icon = "⏩";
const visibleFastForwardCount =
couchDB !== undefined &&
typeof fastForwardCount === "number" &&
Number.isFinite(fastForwardCount) &&
fastForwardCount > 0
? fastForwardCount
: undefined;
const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏩ " + visibleFastForwardCount + ")";
const visibleFastForwardCounts: FastForwardCheckedChanges = {};
if (couchDB !== undefined) {
for (const direction of ["push", "pull"] as const) {
const count = fastForwardCounts?.[direction];
if (typeof count === "number" && Number.isFinite(count) && count > 0) {
visibleFastForwardCounts[direction] = count;
}
}
}
const fastForwardLabels = (["push", "pull"] as const)
.filter((direction) => visibleFastForwardCounts[direction] !== undefined)
.map((direction) => ` (⏩${direction === "push" ? "↑" : "↓"} ${visibleFastForwardCounts[direction]})`);
const fastForwardLabel = fastForwardLabels.join("");
const parts: StatusBarPart[] = [];
if (networkStatus.trackedRequestCount > 0) {
@@ -177,13 +209,15 @@ export function projectReplicationStatusLabel(
parts.push({ key: "network", text: networkActivity });
}
parts.push({ key: "sync", text: "Sync: " + icon });
if (visibleFastForwardCount !== undefined) {
for (const direction of ["push", "pull"] as const) {
const count = visibleFastForwardCounts[direction];
if (count === undefined) continue;
parts.push({
key: "ff",
text: " (⏩ ",
value: visibleFastForwardCount,
key: `ff-${direction}`,
text: ` (⏩${direction === "push" ? "↑" : "↓"} `,
value: count,
suffix: ")",
title: "Compared CouchDB feed entries",
title: `Compared CouchDB ${direction} feed entries`,
});
}
parts.push({ key: "sent", text: " ↑ ", value: sent });
@@ -90,18 +90,22 @@ describe("projectReplicationStatusLabel", () => {
const idleWithCount = projectReplicationStatusLabel(
stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }),
noNetworkActivity,
19
{ push: 19, pull: 19 }
);
expect(idleWithCount.icon).toBe(icon);
expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true);
expect(idleWithCount.parts.some((part) => part.key === "ff-push")).toBe(true);
expect(idleWithCount.parts.some((part) => part.key === "ff-pull")).toBe(true);
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19);
const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, {
push: 19,
pull: 19,
});
expect(withoutCouchDB.icon).toBe(icon);
expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false);
expect(withoutCouchDB.parts.some((part) => part.key.startsWith("ff-"))).toBe(false);
}
const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity);
expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false);
expect(noFastForward.parts.some((part) => part.key.startsWith("ff-"))).toBe(false);
expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0");
});
@@ -123,18 +127,40 @@ describe("projectReplicationStatusLabel", () => {
couchDB: couchDBProgress("continuous", 1200, false),
}),
noNetworkActivity,
1200
{ pull: 1200 }
);
expect(transferred.icon).toBe("⚡");
expect(transferred.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]);
expect(transferred.message).toBe("Sync: ⚡ (⏩↓ 1200) ↑ 0 ↓ 7");
expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff-pull", "sent", "arrived"]);
expect(transferred.parts[1]).toMatchObject({
key: "ff",
text: " (⏩ ",
key: "ff-pull",
text: " (⏩↓ ",
value: 1200,
suffix: ")",
title: "Compared CouchDB pull feed entries",
});
const pushChecking = projectReplicationStatusLabel(
stats({
couchDB: {
phase: "continuous",
push: { checkedChanges: 71975, completedBatches: 2878, checking: true },
},
}),
noNetworkActivity,
{ push: 71975 }
);
expect(pushChecking.icon).toBe("⏩");
expect(pushChecking.message).toBe("Sync: ⏩ (⏩↑ 71975) ↑ 0 ↓ 0");
expect(pushChecking.parts[1]).toMatchObject({ key: "ff-push", value: 71975 });
const bothChecking = projectReplicationStatusLabel(
stats({ couchDB: couchDBProgress("continuous", 10, true) }),
noNetworkActivity,
{ push: 71975, pull: 10 }
);
expect(bothChecking.message).toBe("Sync: ⏩ (⏩↑ 71975) (⏩↓ 10) ↑ 0 ↓ 0");
const idle = projectReplicationStatusLabel(
stats({
syncStatus: "CONNECTED",
@@ -142,10 +168,10 @@ describe("projectReplicationStatusLabel", () => {
couchDB: couchDBProgress("idle", 1200, false),
}),
noNetworkActivity,
1200
{ pull: 1200 }
);
expect(idle.icon).toBe("⚡");
expect(idle.message).toBe("Sync: ⚡ (⏩ 1200) ↑ 0 ↓ 7");
expect(idle.message).toBe("Sync: ⚡ (⏩↓ 1200) ↑ 0 ↓ 7");
});
it("keeps the error icon ahead of an active CouchDB checking indicator", () => {
@@ -154,7 +180,7 @@ describe("projectReplicationStatusLabel", () => {
syncStatus: "ERRORED",
couchDB: {
...couchDBProgress("one-shot", 2, true),
push: { checkedChanges: 0, completedBatches: 0, checking: true },
push: { checkedChanges: 2, completedBatches: 1, checking: true },
},
}),
noNetworkActivity
@@ -181,41 +207,41 @@ describe("createFastForwardCheckedChangesDisplay", () => {
);
const display = createFastForwardCheckedChangesDisplay(source);
expect(display.value).toBe(1200);
expect(display.value).toEqual({ pull: 1200 });
vi.advanceTimersByTime(1000);
source.value = stats({
sent: 3,
couchDB: couchDBProgress("idle", 1200, false),
});
expect(display.value).toBe(1200);
expect(display.value).toEqual({ pull: 1200 });
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001);
expect(display.value).toBe(1200);
expect(display.value).toEqual({ pull: 1200 });
source.value = stats({
sent: 4,
couchDB: couchDBProgress("idle", 1200, false),
});
vi.advanceTimersByTime(1);
expect(display.value).toBeUndefined();
expect(display.value).toEqual({});
source.value = stats({
syncStatus: "PAUSED",
couchDB: couchDBProgress("continuous", 1200, true),
});
expect(display.value).toBeUndefined();
expect(display.value).toEqual({});
source.value = stats({
couchDB: couchDBProgress("continuous", 1201, true),
});
expect(display.value).toBe(1201);
expect(display.value).toEqual({ pull: 1201 });
source.value = stats({
couchDB: couchDBProgress("one-shot", 0, true),
});
expect(display.value).toBeUndefined();
expect(display.value).toEqual({});
source.value = stats();
expect(display.value).toBeUndefined();
expect(display.value).toEqual({});
display.dispose();
});
@@ -227,16 +253,46 @@ describe("createFastForwardCheckedChangesDisplay", () => {
vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS);
source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) });
expect(display.value).toBe(5);
expect(display.value).toEqual({ pull: 5 });
});
it("does not display progress for providers without CouchDB replication state", () => {
const source = reactiveSource(stats());
const display = createFastForwardCheckedChangesDisplay(source);
expect(display.value).toBeUndefined();
expect(display.value).toEqual({});
source.value = stats({ couchDB: { phase: "continuous" } });
expect(display.value).toBeUndefined();
expect(display.value).toEqual({});
display.dispose();
});
it("tracks push and pull independently and expires only the direction that stopped changing", () => {
const source = reactiveSource(
stats({
couchDB: {
phase: "catch-up",
pull: { checkedChanges: 4, completedBatches: 1, checking: true },
push: { checkedChanges: 40, completedBatches: 2, checking: true },
},
})
);
const display = createFastForwardCheckedChangesDisplay(source, 100);
expect(display.value).toEqual({ push: 40, pull: 4 });
vi.advanceTimersByTime(50);
source.value = stats({
couchDB: {
phase: "catch-up",
pull: { checkedChanges: 4, completedBatches: 1, checking: true },
push: { checkedChanges: 41, completedBatches: 2, checking: true },
},
});
expect(display.value).toEqual({ push: 41, pull: 4 });
vi.advanceTimersByTime(50);
expect(display.value).toEqual({ push: 41 });
vi.advanceTimersByTime(50);
expect(display.value).toEqual({});
display.dispose();
});
});
@@ -35,6 +35,8 @@ export function createGeneralSettingSpecGroups({
},
{ key: "showStatusOnStatusbar", control: { type: "toggle" } },
{ key: "hideFileWarningNotice", control: { type: "toggle" } },
{ key: "syncGuardEnabled", control: { type: "toggle" } },
{ key: "syncGuardMinimumFiles", control: { type: "number", min: 1 } },
{
key: "networkWarningStyle",
control: {
@@ -1,6 +1,7 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DEFAULT_SYNC_GUARD_SETTINGS, type SyncGuardSettings } from "@/modules/services/ObsidianSettingService.ts";
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
import type { PageFunctions } from "./SettingPane.ts";
@@ -152,6 +153,7 @@ type SettingsTabOptions = {
};
function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab {
const syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS };
const core = {
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: {
@@ -160,6 +162,10 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
services: {
setting: {
deviceValues: new MemoryValueStore(),
syncGuardSettings,
setSyncGuardSetting: vi.fn(async (key: keyof SyncGuardSettings, value: boolean | number) => {
Object.assign(syncGuardSettings, { [key]: value });
}),
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
},
@@ -179,8 +185,8 @@ function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSe
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, {
_editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
initialSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
_editingSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true },
initialSettings: { ...DEFAULT_SETTINGS, ...syncGuardSettings, useAdvancedMode: true },
});
return tab;
}
@@ -335,6 +341,8 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
]);
expect(
@@ -345,6 +353,39 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]);
});
it("persists declarative SyncGuard controls through Obsidian's local settings service", async () => {
const tab = createSettingsTab();
const setting = tab.services.setting as typeof tab.services.setting & {
setSyncGuardSetting: ReturnType<typeof vi.fn>;
};
const initialCoreSettings = { ...tab.core.settings };
await tab.setControlValue("syncGuardEnabled", true);
await tab.setControlValue("syncGuardMinimumFiles", 6);
expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(1, "syncGuardEnabled", true);
expect(setting.setSyncGuardSetting).toHaveBeenNthCalledWith(2, "syncGuardMinimumFiles", 6);
expect(tab.getControlValue("syncGuardEnabled")).toBe(true);
expect(tab.getControlValue("syncGuardMinimumFiles")).toBe(6);
expect(tab.core.settings).toEqual(initialCoreSettings);
expect(setting.saveSettingData).not.toHaveBeenCalled();
});
it("refreshes active settings when local SyncGuard preferences change elsewhere", async () => {
const tab = createSettingsTab();
tab.isShown = true;
const refresh = vi.spyOn(tab, "requestPageRefresh").mockImplementation(() => undefined);
const setting = tab.services.setting as typeof tab.services.setting & { syncGuardSettings: SyncGuardSettings };
setting.syncGuardSettings.syncGuardEnabled = true;
setting.syncGuardSettings.syncGuardMinimumFiles = 13;
await tab.requestReload();
expect(tab.editingSettings.syncGuardEnabled).toBe(true);
expect(tab.editingSettings.syncGuardMinimumFiles).toBe(13);
expect(refresh).toHaveBeenCalledOnce();
});
it("omits the old Setup child page and keeps standard General and Advanced pages native", () => {
const tab = createSettingsTab();
const pages = collectPages(tab.getSettingDefinitions());
@@ -77,6 +77,11 @@ import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
import {
getSyncGuardSettings,
isSyncGuardSettingKey,
setSyncGuardSetting,
} from "@/modules/services/ObsidianSettingService.ts";
// For creating a document
// const toc = new Set<string>();
@@ -138,6 +143,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (
this.isReconstructionRequiredSetting(k) ||
this.isConfigurationEncryptionSetting(k) ||
isSyncGuardSettingKey(k) ||
k in OnDialogSettingsDefault
) {
// //@ts-ignore
@@ -157,6 +163,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
(e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e)
);
await this.saveConfigurationEncryptionIfDirty(changedKeys);
for (const key of changedKeys.filter(isSyncGuardSettingKey)) {
await setSyncGuardSetting(this.services.setting, key, this.editingSettings[key]);
this.copySettingValue(this.initialSettings, this.editingSettings, key);
}
this.applySetting(changedKeys);
this.reloadAllSettings();
}
@@ -177,6 +187,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
await this.saveConfigurationEncryptionIfDirty(appliedKeys);
for (const k of appliedKeys) {
if (this.isConfigurationEncryptionSetting(k)) continue;
if (isSyncGuardSettingKey(k)) {
await setSyncGuardSetting(this.services.setting, k, this.editingSettings[k]);
this.copySettingValue(this.initialSettings, this.editingSettings, k);
continue;
}
if (k in OnDialogSettingsDefault) {
await this.saveLocalSetting(k as keyof OnDialogSettings);
this.copySettingValue(this.initialSettings, this.editingSettings, k);
@@ -307,7 +322,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
ret.configPassphrase = this.localConfigurationPassphrase;
ret.preset = "";
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
return ret;
return { ...ret, ...getSyncGuardSettings(this.services.setting) };
}
computeAllLocalSettings(): Partial<OnDialogSettings> {
const syncMode = this.editingSettings?.liveSync
@@ -375,7 +390,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
return;
}
const localSetting = this.reloadAllLocalSettings();
if (key in this.core.settings) {
if (isSyncGuardSettingKey(key) || key in this.core.settings) {
if (key in localSetting) {
this.copySettingValue(this.initialSettings, localSetting, key);
this.copySettingValue(this.editingSettings, localSetting, key);
@@ -567,25 +582,25 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
const nativeTabIsShown =
this.supportsDeclarativeSettings() && this.containerEl !== undefined && this.containerEl.isShown();
if (this.isShown || nativeTabIsShown) {
const newConf = this.core.settings;
const keys = Object.keys(newConf) as (keyof ObsidianLiveSyncSettings)[];
const newConf = { ...this.core.settings, ...getSyncGuardSettings(this.services.setting) };
const keys = Object.keys(newConf) as AllSettingItemKey[];
let hasLoaded = false;
let catalogueVisibilityChanged = false;
for (const k of keys) {
if (isObjectDifferent(newConf[k], this.initialSettings?.[k])) {
if (isObjectDifferent(Reflect.get(newConf, k), this.initialSettings?.[k])) {
// Something has changed
if (this.isDirty(k as AllSettingItemKey)) {
if (this.isDirty(k)) {
// And modified.
this.core.confirm.askInPopup(
`config-reloaded-${k}`,
$msg("obsidianLiveSyncSettingTab.msgSettingModified", {
setting: getConfName(k as AllSettingItemKey),
setting: getConfName(k),
}),
(anchor) => {
anchor.text = $msg("obsidianLiveSyncSettingTab.optionHere");
anchor.addEventListener("click", () => {
this.refreshSetting(k as AllSettingItemKey);
if (this.changesPageCatalogue(k as AllSettingItemKey)) {
this.refreshSetting(k);
if (this.changesPageCatalogue(k)) {
this.requestCatalogueRefresh();
} else {
this.requestPageRefresh();
@@ -595,12 +610,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
);
} else {
// not modified
this.refreshSetting(k as AllSettingItemKey);
this.refreshSetting(k);
if (k in OnDialogSettingsDefault) {
continue;
}
hasLoaded = true;
if (this.changesPageCatalogue(k as AllSettingItemKey)) {
if (this.changesPageCatalogue(k)) {
catalogueVisibilityChanged = true;
}
}
@@ -142,6 +142,8 @@ describe("General setting specifications", () => {
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
@@ -1,25 +1,50 @@
export {
AllSettingDefault,
OnDialogSettingsDefault,
SettingInformation,
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
export type {
AllSettings,
AllSettingItemKey,
AllStringItemKey,
AllNumericItemKey,
AllBooleanItemKey,
OnDialogSettings,
ValueOf,
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
export { OnDialogSettingsDefault } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
export type { OnDialogSettings } from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
import {
AllSettingDefault as CommonlibAllSettingDefault,
SettingInformation as CommonlibSettingInformation,
getConfig as getCommonlibConfig,
getConfName as getCommonlibConfName,
type AllSettingItemKey,
type AllSettings as CommonlibAllSettings,
type AllSettingItemKey as CommonlibAllSettingItemKey,
type AllStringItemKey as CommonlibAllStringItemKey,
type AllNumericItemKey as CommonlibAllNumericItemKey,
type AllBooleanItemKey as CommonlibAllBooleanItemKey,
} from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
import type { ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
import { translateLiveSyncMessage } from "@/common/translation";
import {
DEFAULT_SYNC_GUARD_SETTINGS,
isSyncGuardSettingKey,
type SyncGuardSettingKey,
type SyncGuardSettings,
} from "@/modules/services/ObsidianSettingService";
export type AllSettings = Omit<CommonlibAllSettings, SyncGuardSettingKey> & SyncGuardSettings;
export type AllStringItemKey = CommonlibAllStringItemKey;
export type AllNumericItemKey = Exclude<CommonlibAllNumericItemKey, SyncGuardSettingKey> | "syncGuardMinimumFiles";
export type AllBooleanItemKey = Exclude<CommonlibAllBooleanItemKey, SyncGuardSettingKey> | "syncGuardEnabled";
export type AllSettingItemKey = Exclude<CommonlibAllSettingItemKey, SyncGuardSettingKey> | SyncGuardSettingKey;
export type ValueOf<T extends AllSettingItemKey> = AllSettings[T];
const syncGuardSettingInformation: Record<SyncGuardSettingKey, ConfigurationItem> = {
syncGuardEnabled: {
name: "Enable SyncGuard",
desc: "Enable device-local SyncGuard protection during synchronisation.",
},
syncGuardMinimumFiles: {
name: "SyncGuard minimum files",
desc: "Minimum number of Vault files required for SyncGuard protection to apply.",
},
};
export const AllSettingDefault = { ...CommonlibAllSettingDefault, ...DEFAULT_SYNC_GUARD_SETTINGS };
export const SettingInformation: Partial<Record<AllSettingItemKey, ConfigurationItem>> = {
...CommonlibSettingInformation,
...syncGuardSettingInformation,
};
// Commonlib defaults `translate` to its English-only translator, so every caller which omits
// it silently renders English regardless of `displayLanguage`. Default it to the LiveSync
@@ -28,10 +53,12 @@ import { translateLiveSyncMessage } from "@/common/translation";
/** `getConfig` with the LiveSync catalogue applied by default. */
export function getConfig(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key];
return getCommonlibConfig(key, translate);
}
/** `getConfName` with the LiveSync catalogue applied by default. See `getConfig`. */
export function getConfName(key: AllSettingItemKey, translate: MessageTranslator = translateLiveSyncMessage) {
if (isSyncGuardSettingKey(key)) return syncGuardSettingInformation[key].name;
return getCommonlibConfName(key, translate);
}
@@ -314,14 +314,16 @@
{#if processing}
{translateMessage("Checking connection... Please wait.")}
{:else}
{#if setupMode === "settings"}
<InfoNote warning>
{translateMessage(
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
)}
</InfoNote>
{/if}
<UserDecisions>
<Decision title={primaryActionTitle} important disabled={!canProceed} commit={() => checkAndCommit()} />
{#if setupMode === "settings"}
<InfoNote warning>
{translateMessage(
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
)}
</InfoNote>
<Decision
title={translateMessage("Save without connecting")}
disabled={!canProceed}
@@ -67,7 +67,7 @@
gap: 0.5em;
display: flex;
flex-direction: column;
padding-bottom: var(--keyboard-height, 0px);
padding-bottom: 0;
user-select: text;
-webkit-user-select: text;
}
@@ -76,6 +76,10 @@
margin-left: 10px;
}
.dialog-host :global(.button-group > button) {
margin-left: 0;
}
.dialog-host :global(.button-group) {
display: flex;
flex-direction: column;
+91 -2
View File
@@ -8,6 +8,36 @@ import {
type SettingServiceDependencies,
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { EVENT_SYNC_GUARD_PREFERENCES_CHANGED } from "@/common/events.ts";
export type SyncGuardSettings = {
syncGuardEnabled: boolean;
syncGuardMinimumFiles: number;
};
export type SyncGuardSettingKey = keyof SyncGuardSettings;
export const DEFAULT_SYNC_GUARD_SETTINGS: Readonly<SyncGuardSettings> = {
syncGuardEnabled: false,
syncGuardMinimumFiles: 20,
};
export function isSyncGuardSettingKey(key: string): key is SyncGuardSettingKey {
return key === "syncGuardEnabled" || key === "syncGuardMinimumFiles";
}
/** Access the Obsidian host preferences from a service exposed through the shared hub type. */
export function getSyncGuardSettings(setting: SettingService<ObsidianServiceContext>): Readonly<SyncGuardSettings> {
return (setting as ObsidianSettingService<ObsidianServiceContext>).syncGuardSettings;
}
export async function setSyncGuardSetting<K extends SyncGuardSettingKey>(
setting: SettingService<ObsidianServiceContext>,
key: K,
value: SyncGuardSettings[K]
): Promise<void> {
await (setting as ObsidianSettingService<ObsidianServiceContext>).setSyncGuardSetting(key, value);
}
export function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSettings | undefined {
if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
@@ -15,6 +45,13 @@ export function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSe
}
export class ObsidianSettingService<T extends ObsidianServiceContext> extends SettingService<T> {
private _syncGuardSettings: SyncGuardSettings = { ...DEFAULT_SYNC_GUARD_SETTINGS };
private legacySyncGuardSettings: Partial<SyncGuardSettings> = {};
get syncGuardSettings(): Readonly<SyncGuardSettings> {
return this._syncGuardSettings;
}
constructor(context: T, dependencies: SettingServiceDependencies) {
super(context, dependencies);
this.onSettingSaved.addHandler((settings) => {
@@ -26,10 +63,62 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
return Promise.resolve(true);
});
}
override async loadSettings(): Promise<void> {
await super.loadSettings();
const storedEnabled = await this.config.getObject("syncGuardEnabled");
const storedMinimumFiles = await this.config.getObject("syncGuardMinimumFiles");
const enabled = typeof storedEnabled === "boolean" ? storedEnabled : this.legacySyncGuardSettings.syncGuardEnabled;
const minimumFiles =
typeof storedMinimumFiles === "number" && Number.isFinite(storedMinimumFiles) && storedMinimumFiles >= 1
? storedMinimumFiles
: this.legacySyncGuardSettings.syncGuardMinimumFiles;
this._syncGuardSettings = {
syncGuardEnabled: enabled ?? DEFAULT_SYNC_GUARD_SETTINGS.syncGuardEnabled,
syncGuardMinimumFiles: minimumFiles ?? DEFAULT_SYNC_GUARD_SETTINGS.syncGuardMinimumFiles,
};
if (storedEnabled === undefined && enabled !== undefined) {
await this.config.setObject("syncGuardEnabled", enabled);
}
if (storedMinimumFiles === undefined && minimumFiles !== undefined) {
await this.config.setObject("syncGuardMinimumFiles", minimumFiles);
}
}
async setSyncGuardSetting<K extends SyncGuardSettingKey>(key: K, value: SyncGuardSettings[K]): Promise<void> {
if (key === "syncGuardMinimumFiles" && (typeof value !== "number" || !Number.isInteger(value) || value < 1)) {
throw new RangeError("SyncGuard minimum files must be a positive integer");
}
if (this._syncGuardSettings[key] === value) return;
await this.config.setObject(key, value);
this._syncGuardSettings = { ...this._syncGuardSettings, [key]: value };
// These preferences change only the workspace overlay. Re-applying all
// replication settings would unnecessarily interrupt an active sync.
this.context.events.emitEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED);
}
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
return await this.context.liveSyncPlugin.saveData(data);
const persisted: Record<string, unknown> = { ...data };
delete persisted.syncGuardEnabled;
delete persisted.syncGuardMinimumFiles;
return await this.context.liveSyncPlugin.saveData(persisted);
}
protected override async loadData(): Promise<ObsidianLiveSyncSettings | undefined> {
return normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData());
const data = normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData());
if (!data) return undefined;
const persisted: Record<string, unknown> = { ...data };
const { syncGuardEnabled, syncGuardMinimumFiles } = persisted;
this.legacySyncGuardSettings = {
...(typeof syncGuardEnabled === "boolean" ? { syncGuardEnabled } : {}),
...(typeof syncGuardMinimumFiles === "number" &&
Number.isFinite(syncGuardMinimumFiles) &&
syncGuardMinimumFiles >= 1
? { syncGuardMinimumFiles }
: {}),
};
delete persisted.syncGuardEnabled;
delete persisted.syncGuardMinimumFiles;
return persisted as unknown as ObsidianLiveSyncSettings;
}
}
@@ -1,20 +1,29 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { EVENT_SYNC_GUARD_PREFERENCES_CHANGED } from "@/common/events.ts";
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import { MemoryValueStore } from "@vrtmrz/value-store";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
import { ObsidianSettingService } from "./ObsidianSettingService.ts";
import { getSyncGuardSettings, ObsidianSettingService, setSyncGuardSetting } from "./ObsidianSettingService.ts";
function createSettingService(
vaultName: string,
deviceValues: MemoryValueStore
deviceValues: MemoryValueStore,
savedData?: unknown,
saveData = vi.fn(async (_settings: unknown) => undefined)
): ObsidianSettingService<ObsidianServiceContext> {
const context = createServiceContext() as unknown as ObsidianServiceContext;
context.liveSyncPlugin = {
loadData: vi.fn(async () => savedData),
saveData,
} as unknown as ObsidianServiceContext["liveSyncPlugin"];
const APIService = {
addLog: () => undefined,
getSystemVaultName: () => vaultName,
getAppID: () => "test-app",
} as unknown as IAPIService;
return new ObsidianSettingService(context, { APIService, deviceValues });
}
@@ -52,3 +61,102 @@ describe("normaliseObsidianSettingsData", () => {
await expect(secondValues.getValue("second-vault-shared-key")).resolves.toBe("second config");
});
});
describe("SyncGuard device-local settings", () => {
const savedSettings = { ...DEFAULT_SETTINGS, isConfigured: true, additionalSuffixOfDatabaseName: "" };
it("loads safe defaults when no local values were saved", async () => {
const setting = createSettingService("first-vault", new MemoryValueStore(), savedSettings);
await setting.loadSettings();
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 20 });
});
it("migrates preferences stored by earlier builds and excludes them from plugin data", async () => {
const localValues = new MemoryValueStore();
const saveData = vi.fn(async (_data: unknown) => undefined);
const setting = createSettingService(
"first-vault",
localValues,
{ ...savedSettings, syncGuardEnabled: true, syncGuardMinimumFiles: 7 },
saveData
);
await setting.loadSettings();
await setting.saveSettingData();
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 7 });
await expect(setting.config.getObject("syncGuardEnabled")).resolves.toBe(true);
await expect(setting.config.getObject("syncGuardMinimumFiles")).resolves.toBe(7);
expect(saveData.mock.lastCall?.[0]).not.toHaveProperty("syncGuardEnabled");
expect(saveData.mock.lastCall?.[0]).not.toHaveProperty("syncGuardMinimumFiles");
});
it("keeps existing device-local values authoritative over legacy plugin data", async () => {
const localValues = new MemoryValueStore();
const setting = createSettingService(
"first-vault",
localValues,
{ ...savedSettings, syncGuardEnabled: true, syncGuardMinimumFiles: 5 }
);
await localValues.setObject("first-vault--syncGuardEnabled", false);
await localValues.setObject("first-vault--syncGuardMinimumFiles", 30);
await setting.loadSettings();
expect(getSyncGuardSettings(setting)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 30 });
});
it("restores saved preferences after recreating the service", async () => {
const localValues = new MemoryValueStore();
const first = createSettingService("first-vault", localValues, savedSettings);
await first.loadSettings();
await setSyncGuardSetting(first, "syncGuardEnabled", true);
await setSyncGuardSetting(first, "syncGuardMinimumFiles", 12);
const reloaded = createSettingService("first-vault", localValues, savedSettings);
await reloaded.loadSettings();
expect(getSyncGuardSettings(reloaded)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 12 });
});
it("rejects invalid file thresholds without changing local settings", async () => {
const setting = createSettingService("first-vault", new MemoryValueStore(), savedSettings);
await setting.loadSettings();
const storedBefore = await setting.config.getObject("syncGuardMinimumFiles");
for (const invalid of [0, -1, 1.5, NaN, Infinity]) {
await expect(setSyncGuardSetting(setting, "syncGuardMinimumFiles", invalid)).rejects.toThrow(RangeError);
}
expect(getSyncGuardSettings(setting).syncGuardMinimumFiles).toBe(20);
await expect(setting.config.getObject("syncGuardMinimumFiles")).resolves.toBe(storedBefore);
});
it("persists edits only in this Vault's device store and notifies the guard", async () => {
const localValues = new MemoryValueStore();
const saveData = vi.fn(async (_data: unknown) => undefined);
const first = createSettingService("first-vault", localValues, savedSettings, saveData);
const second = createSettingService("second-vault", localValues, savedSettings);
await first.loadSettings();
await second.loadSettings();
const initialSaveCount = saveData.mock.calls.length;
const changed = vi.fn();
const unrelatedReplicationSettingsSaved = vi.fn();
first.context.events.onEvent(EVENT_SYNC_GUARD_PREFERENCES_CHANGED, changed);
first.context.events.onEvent(EVENT_SETTING_SAVED, unrelatedReplicationSettingsSaved);
await setSyncGuardSetting(first, "syncGuardEnabled", true);
await setSyncGuardSetting(first, "syncGuardMinimumFiles", 8);
await setSyncGuardSetting(first, "syncGuardEnabled", true);
expect(getSyncGuardSettings(first)).toEqual({ syncGuardEnabled: true, syncGuardMinimumFiles: 8 });
expect(getSyncGuardSettings(second)).toEqual({ syncGuardEnabled: false, syncGuardMinimumFiles: 20 });
await expect(localValues.getObject("first-vault--syncGuardEnabled")).resolves.toBe(true);
await expect(localValues.getObject("first-vault--syncGuardMinimumFiles")).resolves.toBe(8);
expect(changed).toHaveBeenCalledTimes(2);
expect(unrelatedReplicationSettingsSaved).not.toHaveBeenCalled();
expect(saveData).toHaveBeenCalledTimes(initialSaveCount);
});
});