Add opt-in Unicode NFC path identity for CouchDB

This commit is contained in:
vorotamoroz
2026-10-17 13:31:19 +00:00
parent 72dc88c7ef
commit ddb4d52931
39 changed files with 2245 additions and 97 deletions
@@ -1,4 +1,5 @@
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "@/common/translation";
import type { SettingSpecGroup } from "./SettingSpec.ts";
export type AdvancedSettingSpecContext = {
@@ -22,6 +23,18 @@ export function createAdvancedSettingSpecGroups({
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
},
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
{
key: "pathNormalizationVersion",
holdValue: true,
control: {
type: "numeric-dropdown",
options: () => ({
"0": $msg("Legacy path identity"),
"1": $msg("Unicode NFC path identity"),
}),
},
visible: isCouchDB,
},
],
},
{
@@ -243,6 +243,29 @@ export class LiveSyncSetting extends Setting {
});
return this;
}
autoWireNumericDropDown(key: AllNumericItemKey, opt: AutoWireOption & { options: Record<string, string> }) {
const conf = this.autoWireSetting(key, opt);
this.addDropdown((dropdown) => {
this.autoWiredComponent = dropdown;
const setValue = wrapMemo((value: string) => {
dropdown.setValue(value);
});
dropdown.addOptions(opt.options);
this.invalidateValue = () => setValue(`${LiveSyncSetting.env.editingSettings[key] ?? ""}`);
this.invalidateValue();
dropdown.onChange(async (value) => {
const numericValue = Number(value);
if (!Number.isInteger(numericValue) || !Object.prototype.hasOwnProperty.call(opt.options, value)) {
return false;
}
await this.commitValue(numericValue);
});
this.autoWireComponent(this.autoWiredComponent, conf, opt);
});
return this;
}
addApplyButton(keys: AllSettingItemKey[], text?: string) {
this.addButton((button) => {
this.applyButtonComponent = button;
@@ -133,7 +133,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
applySetting(keys: AllSettingItemKey[]) {
for (const k of keys) {
if (!this.isDirty(k)) continue;
if (this.isConfigurationEncryptionSetting(k) || k in OnDialogSettingsDefault) {
if (
this.isReconstructionRequiredSetting(k) ||
this.isConfigurationEncryptionSetting(k) ||
k in OnDialogSettingsDefault
) {
// //@ts-ignore
// this.initialSettings[k] = this.editingSettings[k];
continue;
@@ -147,8 +151,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
});
}
async applyAllSettings() {
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter((e) =>
this.isDirty(e)
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter(
(e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e)
);
await this.saveConfigurationEncryptionIfDirty(changedKeys);
this.applySetting(changedKeys);
@@ -168,7 +172,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
*/
async saveSettings(keys: AllSettingItemKey[]) {
let hasChanged = false;
const appliedKeys = keys.filter((k) => this.isDirty(k));
const appliedKeys = keys.filter((k) => this.isDirty(k) && !this.isReconstructionRequiredSetting(k));
await this.saveConfigurationEncryptionIfDirty(appliedKeys);
for (const k of appliedKeys) {
if (this.isConfigurationEncryptionSetting(k)) continue;
@@ -193,11 +197,16 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
await Promise.all(handlers);
// }
keys.forEach((e) => {
if (this.isReconstructionRequiredSetting(e) && this.isDirty(e)) return;
if (this.isConfigurationEncryptionSetting(e) && this.isDirty(e)) return;
this.refreshSetting(e);
});
}
private isReconstructionRequiredSetting(key: AllSettingItemKey): boolean {
return key === "pathNormalizationVersion";
}
private isConfigurationEncryptionSetting(key: AllSettingItemKey): boolean {
return key === "configPassphraseStore" || key === "configPassphrase";
}
@@ -225,8 +234,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
* @param keys setting keys for applying
*/
async saveAllDirtySettings() {
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter((e) =>
this.isDirty(e)
const changedKeys = (Object.keys(this.editingSettings ?? {}) as AllSettingItemKey[]).filter(
(e) => this.isDirty(e) && !this.isReconstructionRequiredSetting(e)
);
await this.saveSettings(changedKeys);
this.reloadAllSettings();
@@ -299,6 +308,13 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
reloadAllSettings(skipUpdate: boolean = false) {
const previousSettings = this._editingSettings;
const previousSnapshot = this.initialSettings;
const pendingPathNormalizationVersion =
previousSettings && previousSnapshot && this.isDirty("pathNormalizationVersion")
? {
value: previousSettings.pathNormalizationVersion,
appliedValue: previousSnapshot.pathNormalizationVersion,
}
: undefined;
const pendingConfigurationEncryption =
previousSettings && previousSnapshot
? {
@@ -326,6 +342,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (pendingConfigurationEncryption?.configPassphrase) {
this._editingSettings.configPassphrase = pendingConfigurationEncryption.configPassphrase.value;
}
if (pendingPathNormalizationVersion) {
this._editingSettings.pathNormalizationVersion = pendingPathNormalizationVersion.value;
this.initialSettings.pathNormalizationVersion = pendingPathNormalizationVersion.appliedValue;
}
if (!skipUpdate) this.requestUpdate();
}
@@ -333,6 +353,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
* Reread each setting and request invalidate
*/
refreshSetting(key: AllSettingItemKey) {
if (this.isReconstructionRequiredSetting(key) && this.isDirty(key)) {
this.requestUpdate();
return;
}
const localSetting = this.reloadAllLocalSettings();
if (key in this.core.settings) {
if (key in localSetting) {
@@ -627,6 +651,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
"passphrase",
"useDynamicIterationCount",
"usePathObfuscation",
"pathNormalizationVersion",
"encrypt",
// "remoteType",
]);
@@ -637,6 +662,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
"passphrase",
"useDynamicIterationCount",
"usePathObfuscation",
"pathNormalizationVersion",
"encrypt",
]);
}
@@ -682,16 +708,23 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
override getControlValue(key: string): unknown {
const spec = this.getDeclarativeSettingSpec(key);
return this.editingSettings[spec.key];
const value = this.editingSettings[spec.key];
return spec.control.type === "numeric-dropdown" ? `${value}` : value;
}
override async setControlValue(key: string, value: unknown): Promise<void> {
const spec = this.getDeclarativeSettingSpec(key);
if (!isValidSettingSpecValue(spec, value)) {
const persistedValue =
spec.control.type === "numeric-dropdown" && typeof value === "string" ? Number(value) : value;
if (!isValidSettingSpecValue(spec, persistedValue)) {
throw new TypeError(`Invalid value for declarative setting ${key}`);
}
Reflect.set(this.editingSettings, spec.key, value);
await this.saveSettings([spec.key]);
Reflect.set(this.editingSettings, spec.key, persistedValue);
if (spec.holdValue) {
this.requestUpdate();
} else {
await this.saveSettings([spec.key]);
}
}
private createRebuildRequiredAction(): SettingDefinitionAction {
@@ -1059,6 +1092,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
};
rebuildDB = async (method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks") => {
if (method === "remoteOnly" && this.isDirty("pathNormalizationVersion")) {
Logger(
$msg(
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
),
LOG_LEVEL_NOTICE
);
return;
}
if (this.editingSettings.encrypt && this.editingSettings.passphrase == "") {
Logger($msg("obsidianLiveSyncSettingTab.logEncryptionNoPassphrase"), LOG_LEVEL_NOTICE);
return;
@@ -1109,6 +1151,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.editingSettings.passphrase = "";
}
await this.saveAllDirtySettings();
await this.applyReconstructionRequiredSettings();
},
});
if (result.result === "scheduled") {
@@ -1118,6 +1161,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (result.result === "failed") {
return;
}
if (this.isDirty("pathNormalizationVersion")) {
return;
}
const applyWithoutInitialisation = $msg(
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation"
@@ -1136,6 +1182,23 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
}
private async applyReconstructionRequiredSettings(): Promise<void> {
const key = "pathNormalizationVersion";
if (!this.isDirty(key)) return;
const previousValue = this.core.settings.pathNormalizationVersion;
this.copySettingValue(this.core.settings, this.editingSettings, key);
try {
await this.services.setting.saveSettingData();
} catch (error) {
this.core.settings.pathNormalizationVersion = previousValue;
throw error;
}
this.copySettingValue(this.initialSettings, this.core.settings, key);
this.refreshSetting(key);
}
// The imperative renderer remains required by the declared Obsidian 1.7.2 minimum version.
override display(): void {
this.displayImperative();
@@ -316,6 +316,117 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
};
}
it("requires local and remote rebuilds when path identity changes", () => {
const { tab } = createSettingsTab();
const initialSettings = tab.initialSettings!;
initialSettings.handleFilenameCaseSensitive = false;
tab.editingSettings.handleFilenameCaseSensitive = false;
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
expect(tab.isNeedRebuildLocal()).toBe(true);
expect(tab.isNeedRebuildRemote()).toBe(true);
});
it("does not offer settings-only fallback when path identity changes", async () => {
const { tab, saveSettingData, confirmWithMessage } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
result: "cancelled",
});
await tab.confirmRebuild();
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(saveSettingData).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(Reflect.get(initialSettings, "pathNormalizationVersion")).toBe(0);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
});
it.each([
["regular-expression Apply", "syncOnlyRegEx", "notes/.*"],
["preset Apply", "preset", "LIVESYNC"],
] as const)("keeps path identity pending during %s", async (_label, setting, value) => {
const { tab } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
Reflect.set(tab.editingSettings, setting, value);
if (setting === "preset") {
tab.addOnSaved("preset", async () => {
tab.editingSettings.liveSync = true;
await tab.saveAllDirtySettings();
});
}
await tab.saveAllDirtySettings();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
if (setting === "syncOnlyRegEx") {
expect(tab.core.settings.syncOnlyRegEx).toBe(value);
} else {
expect(tab.core.settings.liveSync).toBe(true);
}
});
it("excludes path identity from direct save operations", async () => {
const { tab, saveSettingData } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
await tab.saveSettings(["pathNormalizationVersion"]);
expect(saveSettingData).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
});
it("excludes path identity from ordinary Apply operations", async () => {
const { tab, saveSettingData } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
await tab.applyAllSettings();
expect(saveSettingData).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
});
it("retains a pending path identity edit and its applied baseline after reload", () => {
const { tab } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
tab.reloadAllSettings();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(tab.isNeedRebuildLocal()).toBe(true);
expect(tab.isNeedRebuildRemote()).toBe(true);
});
it("keeps pending settings in the editing buffer when initialisation and the fallback are cancelled", async () => {
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
@@ -353,8 +464,11 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
expect(closeSetting).not.toHaveBeenCalled();
});
it("closes settings only after initialisation has been scheduled", async () => {
it("applies path identity only from a successful initialisation reservation callback", async () => {
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(tab.initialSettings!, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockImplementationOnce(
async ({ applySettings }: { applySettings: () => Promise<void> }) => {
await applySettings();
@@ -364,9 +478,118 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
await tab.confirmRebuild();
expect(saveSettingData).toHaveBeenCalledOnce();
expect(saveSettingData).toHaveBeenCalledTimes(2);
expect(confirmWithMessage).not.toHaveBeenCalled();
expect(closeSetting).toHaveBeenCalledOnce();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(1);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(1);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(false);
});
it("rolls path identity back and keeps it pending when saving after reservation fails", async () => {
const { tab, saveSettingData, closeSetting } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
tab.editingSettings.handleFilenameCaseSensitive = false;
const failure = new Error("settings could not be saved");
saveSettingData.mockRejectedValueOnce(failure);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockImplementationOnce(
async ({ applySettings }: { applySettings: () => Promise<void> }) => {
await applySettings();
return { result: "scheduled", mode: "rebuild" };
}
);
await expect(tab.confirmRebuild()).rejects.toBe(failure);
expect(saveSettingData).toHaveBeenCalledOnce();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(closeSetting).not.toHaveBeenCalled();
});
it("retains pending path identity when fetch validation fails", async () => {
const { tab } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
vi.spyOn(tab, "checkWorkingPassphrase").mockResolvedValue(false);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockImplementationOnce(
async ({ validateChoice }: { validateChoice: (mode: "fetch" | "rebuild") => Promise<boolean> }) => {
expect(await validateChoice("fetch")).toBe(false);
return { result: "failed", mode: "fetch" };
}
);
await tab.confirmRebuild();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(tab.closeSetting).not.toHaveBeenCalled();
});
it("retains pending path identity when initialisation reservation fails", async () => {
const { tab, saveSettingData } = createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
result: "failed",
mode: "rebuild",
});
await tab.confirmRebuild();
expect(saveSettingData).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(tab.closeSetting).not.toHaveBeenCalled();
});
it("refuses remote-only overwrite with pending path identity before side effects", async () => {
const { tab, saveSettingData, suspendAllSync, suspendExtraSync, performRebuildDB, closeSetting } =
createSettingsTab();
const initialSettings = tab.initialSettings!;
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
tab.editingSettings.encrypt = false;
const applyAllSettings = vi.spyOn(tab, "applyAllSettings");
const reloadAllSettings = vi.spyOn(tab, "reloadAllSettings");
await tab.rebuildDB("remoteOnly");
expect(applyAllSettings).not.toHaveBeenCalled();
expect(suspendAllSync).not.toHaveBeenCalled();
expect(suspendExtraSync).not.toHaveBeenCalled();
expect(reloadAllSettings).not.toHaveBeenCalled();
expect(saveSettingData).not.toHaveBeenCalled();
expect(performRebuildDB).not.toHaveBeenCalled();
expect(closeSetting).not.toHaveBeenCalled();
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.initialSettings!, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(vi.mocked($msg)).toHaveBeenCalledWith(
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
);
expect(loggerMocks.Logger).toHaveBeenCalledWith(
expect.stringContaining(
"Rebuild both databases from a complete authoritative Vault and Fetch on the other devices."
),
LOG_LEVEL_NOTICE
);
});
it("does not offer the settings-only fallback after an initialisation failure", async () => {
@@ -489,6 +712,33 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
expect(saveSettings).toHaveBeenCalledWith(["hashCacheMaxCount"]);
});
it("keeps a path identity dropdown edit pending until the rebuild flow applies it", async () => {
const { tab, saveSettingData } = createSettingsTab();
const initialSettings = tab.initialSettings!;
const saveSettings = vi.spyOn(tab, "saveSettings");
vi.spyOn(tab, "requestUpdate").mockImplementation(() => undefined);
Reflect.set(tab.core.settings, "pathNormalizationVersion", 0);
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 0);
Reflect.set(initialSettings, "pathNormalizationVersion", 0);
expect(tab.getControlValue("pathNormalizationVersion")).toBe("0");
await tab.setControlValue("pathNormalizationVersion", "1");
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.getControlValue("pathNormalizationVersion")).toBe("1");
expect(Reflect.get(tab.core.settings, "pathNormalizationVersion")).toBe(0);
expect(Reflect.get(initialSettings, "pathNormalizationVersion")).toBe(0);
expect(saveSettings).not.toHaveBeenCalled();
expect(tab.requestUpdate).toHaveBeenCalledOnce();
expect(saveSettingData).not.toHaveBeenCalled();
expect(tab.isNeedRebuildLocal()).toBe(true);
expect(tab.isNeedRebuildRemote()).toBe(true);
await expect(tab.setControlValue("pathNormalizationVersion", "0.5")).rejects.toThrow(
/Invalid value for declarative setting/u
);
});
it("rejects unregistered declarative control keys", async () => {
const { tab } = createSettingsTab();
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
};
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(9));
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"Memory cache",
@@ -55,6 +55,7 @@ describe("paneAdvanced", () => {
panelElements.get("Memory cache"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
@@ -66,6 +67,7 @@ describe("paneAdvanced", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -11,6 +11,7 @@ export type PersistedSettingKey = PersistedBooleanSettingKey | PersistedStringSe
type SettingSpecBase<K extends PersistedSettingKey, C> = {
key: K;
control: C;
holdValue?: boolean;
visible?: () => boolean;
disabled?: () => boolean;
aliases?: string[];
@@ -34,6 +35,14 @@ type NumberSettingSpec = SettingSpecBase<
}
>;
type NumericDropdownSettingSpec = SettingSpecBase<
PersistedNumericSettingKey,
{
type: "numeric-dropdown";
options: () => Record<string, string>;
}
>;
type DropdownSettingSpec = SettingSpecBase<
PersistedStringSettingKey,
{
@@ -42,7 +51,7 @@ type DropdownSettingSpec = SettingSpecBase<
}
>;
export type SettingSpec = ToggleSettingSpec | NumberSettingSpec | DropdownSettingSpec;
export type SettingSpec = ToggleSettingSpec | NumberSettingSpec | NumericDropdownSettingSpec | DropdownSettingSpec;
/** An existing Advanced-page panel and the standard controls rendered within it. */
export type SettingSpecGroup = {
@@ -54,7 +63,7 @@ export type SettingSpecMessages = {
valueShouldBeInRange: (range: { min?: number; max?: number }) => string;
};
type LegacySettingOptions = Pick<AutoWireOption, "onUpdate">;
type LegacySettingOptions = Pick<AutoWireOption, "holdValue" | "onUpdate">;
export type LegacySettingBinding =
| {
@@ -71,6 +80,11 @@ export type LegacySettingBinding =
acceptZero?: boolean;
};
}
| {
type: "numeric-dropdown";
key: PersistedNumericSettingKey;
options: LegacySettingOptions & { options: Record<string, string> };
}
| {
type: "dropdown";
key: PersistedStringSettingKey;
@@ -83,6 +97,10 @@ export interface LegacySettingSpecRenderer {
key: AllNumericItemKey,
options: AutoWireOption & { clampMin?: number; clampMax?: number; acceptZero?: boolean }
): unknown;
autoWireNumericDropDown(
key: AllNumericItemKey,
options: AutoWireOption & { options: Record<string, string> }
): unknown;
autoWireDropDown(key: AllStringItemKey, options: AutoWireOption & { options: Record<string, string> }): unknown;
}
@@ -94,15 +112,21 @@ function isNumberSettingSpec(spec: SettingSpec): spec is NumberSettingSpec {
return spec.control.type === "number";
}
function isNumericDropdownSettingSpec(spec: SettingSpec): spec is NumericDropdownSettingSpec {
return spec.control.type === "numeric-dropdown";
}
function toLegacyUpdateOptions(spec: SettingSpec): LegacySettingOptions {
if (!spec.visible && !spec.disabled) {
return {};
}
return {
onUpdate: (): OnUpdateResult => ({
...(spec.visible ? { visibility: spec.visible() } : {}),
...(spec.disabled ? { disabled: spec.disabled() } : {}),
}),
...(spec.holdValue ? { holdValue: spec.holdValue } : {}),
...(!spec.visible && !spec.disabled
? {}
: {
onUpdate: (): OnUpdateResult => ({
...(spec.visible ? { visibility: spec.visible() } : {}),
...(spec.disabled ? { disabled: spec.disabled() } : {}),
}),
}),
};
}
@@ -131,6 +155,16 @@ export function toLegacySettingBinding(spec: SettingSpec): LegacySettingBinding
},
};
}
if (isNumericDropdownSettingSpec(spec)) {
return {
type: "numeric-dropdown",
key: spec.key,
options: {
...updateOptions,
options: spec.control.options(),
},
};
}
return {
type: "dropdown",
key: spec.key,
@@ -151,6 +185,9 @@ export function renderLegacySettingSpec(renderer: LegacySettingSpecRenderer, spe
case "number":
renderer.autoWireNumeric(binding.key, binding.options);
return;
case "numeric-dropdown":
renderer.autoWireNumericDropDown(binding.key, binding.options);
return;
case "dropdown":
renderer.autoWireDropDown(binding.key, binding.options);
}
@@ -176,6 +213,12 @@ export function isValidSettingSpecValue(spec: SettingSpec, value: unknown): bool
return typeof value === "boolean";
case "number":
return typeof value === "number" && !numberIsOutOfRange(value, spec.control);
case "numeric-dropdown":
return (
typeof value === "number" &&
Number.isInteger(value) &&
Object.prototype.hasOwnProperty.call(spec.control.options(), `${value}`)
);
case "dropdown":
return typeof value === "string" && Object.prototype.hasOwnProperty.call(spec.control.options(), value);
}
@@ -229,6 +272,14 @@ export function toObsidianSettingDefinition(
};
break;
}
case "numeric-dropdown":
control = {
type: "dropdown",
key: spec.key,
options: spec.control.options(),
...(spec.disabled ? { disabled: spec.disabled } : {}),
};
break;
case "dropdown":
control = {
type: "dropdown",
@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { statusDisplay, type ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getConfig, type OnDialogSettings } from "./settingConstants.ts";
import {
isValidSettingSpecValue,
renderLegacySettingSpec,
toLegacySettingBinding,
toObsidianSettingDefinition,
@@ -16,7 +17,7 @@ const rangeMessages = {
};
describe("Advanced setting specifications", () => {
it("lists only the nine controls currently exposed by the Advanced page", () => {
it("lists only the ten controls currently exposed by the Advanced page", () => {
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
expect(groups.map(({ heading }) => heading)).toEqual([
@@ -29,6 +30,7 @@ describe("Advanced setting specifications", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -38,12 +40,13 @@ describe("Advanced setting specifications", () => {
]);
});
it("keeps CouchDB visibility on the four remote chunk controls", () => {
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
let couchDB = false;
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
const conditional = groups.flatMap(({ items }) => items).filter(({ visible }) => visible !== undefined);
expect(conditional.map(({ key }) => key)).toEqual([
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -55,6 +58,44 @@ describe("Advanced setting specifications", () => {
expect(conditional.every(({ visible }) => visible?.() === true)).toBe(true);
});
it("exposes CouchDB path identity as a two-choice numeric dropdown", () => {
let couchDB = false;
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB })
.flatMap(({ items }) => items)
.find(({ key }) => key === "pathNormalizationVersion");
if (!spec) throw new Error("Path identity mode is not exposed on the Advanced page");
if (spec.control.type !== "numeric-dropdown") throw new Error("Expected a numeric dropdown");
expect(spec.control).toMatchObject({ type: "numeric-dropdown", options: expect.any(Function) });
expect(spec.visible?.()).toBe(false);
couchDB = true;
expect(spec.visible?.()).toBe(true);
expect(spec.control.options()).toEqual({
"0": "Legacy path identity",
"1": "Unicode NFC path identity",
});
expect(isValidSettingSpecValue(spec, 0)).toBe(true);
expect(isValidSettingSpecValue(spec, 1)).toBe(true);
expect(isValidSettingSpecValue(spec, 0.5)).toBe(false);
expect(isValidSettingSpecValue(spec, 2)).toBe(false);
const definition = toObsidianSettingDefinition(spec, { name: "Path identity mode" }, rangeMessages);
expect(definition.control).toEqual({
type: "dropdown",
key: "pathNormalizationVersion",
options: { "0": "Legacy path identity", "1": "Unicode NFC path identity" },
});
expect(toLegacySettingBinding(spec)).toEqual({
type: "numeric-dropdown",
key: "pathNormalizationVersion",
options: {
holdValue: true,
onUpdate: expect.any(Function),
options: { "0": "Legacy path identity", "1": "Unicode NFC path identity" },
},
});
});
it("excludes transient settings-dialogue keys from the persisted key type", () => {
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
@@ -225,6 +266,7 @@ describe("SettingSpec conversion", () => {
const renderer = {
autoWireToggle: vi.fn(),
autoWireNumeric: vi.fn(),
autoWireNumericDropDown: vi.fn(),
autoWireDropDown: vi.fn(),
};
const visible = vi.fn(() => false);
@@ -241,6 +283,7 @@ describe("SettingSpec conversion", () => {
expect(options.onUpdate()).toEqual({ visibility: false });
expect(visible).toHaveBeenCalledOnce();
expect(renderer.autoWireNumeric).not.toHaveBeenCalled();
expect(renderer.autoWireNumericDropDown).not.toHaveBeenCalled();
expect(renderer.autoWireDropDown).not.toHaveBeenCalled();
});
});
@@ -86,6 +86,7 @@ describe("settings page catalogue", () => {
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",