mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-21 16:52:32 +00:00
Add opt-in Unicode NFC path identity for CouchDB
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user