Expose NFC path identity across supported transports

This commit is contained in:
vorotamoroz committed 2026-11-01 10:19:08 +00:00
1 parent 0e458e5e20
commit ca4e6d38b9
22 files changed
+1003 -268

No files matched your search

+7 -3
View File
@@ -382,7 +382,8 @@ The CLI uses the same settings format as the Obsidian plug-in. Create a `.livesy
#### Unicode path identity
The `pathNormalizationVersion` setting controls how file paths identify
Metadata documents when using CouchDB. An absent value or `0` keeps the legacy
Metadata documents. CouchDB support is released; the unreleased extension also
supports Object Storage and P2P. An absent value or `0` keeps the legacy
identity. Version `1` uses Unicode Normalization Form C (NFC) for Metadata IDs
and path comparison keys. It does not rewrite `Metadata.path`, rename existing
files, or change Chunks or file content. `handleFilenameCaseSensitive` remains
@@ -406,8 +407,11 @@ with `--force --write-settings`. Set the same value on each remaining CLI
client, then run `fetch` with `--force --write-settings`. Use **Overwrite
Server Data with This Device's Files** and **Reset Synchronisation on This
Device** for the corresponding Obsidian Rebuild and Fetch operations. Resolve
any ambiguous names before reconstruction. Version `1` is supported with
CouchDB only; do not resume a legacy client against the rebuilt database.
any ambiguous names before reconstruction. Object Storage Rebuild replaces the
remote journals as well as the local database. P2P Rebuild prepares only the
source local database; keep that peer online for the other devices to Fetch.
Every P2P participant must be updated and migrated. Do not resume a legacy
client against the rebuilt database.
### Command-line Reference
@@ -7,6 +7,9 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"File identity": "File identity",
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.":
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.",
"Synchronisation protection": "Synchronisation protection",
"Synchronising your Vault": "Synchronising your Vault",
"Receiving changes from the server…": "Receiving changes from the server…",
@@ -1,5 +1,4 @@
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "@/common/translation";
import type { SettingSpecGroup } from "./SettingSpec.ts";
export type AdvancedSettingSpecContext = {
@@ -24,18 +23,6 @@ export function createAdvancedSettingSpecGroups({
},
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
{
key: "pathNormalizationVersion",
holdValue: true,
control: {
type: "numeric-dropdown",
options: () => ({
"0": $msg("Legacy path identity"),
"1": $msg("Unicode NFC path identity"),
}),
},
visible: isCouchDB,
},
],
},
{
@@ -8,7 +8,7 @@ export type GeneralSettingSpecContext = {
showVerboseLog: () => boolean;
};
/** Build the shared Appearance and Logging controls. */
/** Build the shared General Settings controls. */
export function createGeneralSettingSpecGroups({
showEditorStatusDetails,
showVerboseLog,
@@ -61,6 +61,22 @@ export function createGeneralSettingSpecGroups({
},
],
},
{
heading: "File identity",
items: [
{
key: "pathNormalizationVersion",
holdValue: true,
control: {
type: "numeric-dropdown",
options: () => ({
"0": $msg("Legacy path identity"),
"1": $msg("Unicode NFC path identity"),
}),
},
},
],
},
];
}
@@ -319,7 +319,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
).toEqual(["Copy the current settings to a Setup URI", "Show QR code"]);
});
it("keeps Appearance, Logging, and Extra menus inside General Settings", () => {
it("keeps Appearance, Logging, File identity, and Extra menus inside General Settings", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
const general = definitions.find(
@@ -332,7 +332,19 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
(item): item is SettingDefinitionPage => isPage(item) && item.name === "🎚️ Extra menus"
);
expect(generalPages?.map(({ name }) => name)).toEqual(["🎨 Appearance", "📝 Logging", "🎚️ Extra menus"]);
expect(generalPages?.map(({ name }) => name)).toEqual([
"🎨 Appearance",
"📝 Logging",
"🗂️ File identity",
"🎚️ Extra menus",
]);
const fileIdentity = generalPages?.find(({ name }) => name === "🗂️ File identity");
expect(
fileIdentity?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual(["pathNormalizationVersion"]);
expect(
fileIdentity?.items?.filter((item) => "action" in item && typeof item.action === "function")
).toHaveLength(1);
expect(
appearance?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual([
@@ -390,7 +402,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
const tab = createSettingsTab();
const pages = collectPages(tab.getSettingDefinitions());
expect(pages).toHaveLength(14);
expect(pages).toHaveLength(15);
expect(pages.map(({ name }) => name)).toEqual(
expect.arrayContaining(
createSettingsPageCatalogue()
@@ -892,9 +892,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true),
showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false),
});
const [appearance, logging] = groups;
if (!appearance || !logging) {
throw new Error("General settings must define Appearance and Logging groups");
const [appearance, logging, fileIdentity] = groups;
if (!appearance || !logging || !fileIdentity) {
throw new Error("General settings must define Appearance, Logging, and File identity groups");
}
return this.createRootGroup("general-settings", [
{
@@ -907,6 +907,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
name: `📝 ${logging.heading}`,
items: logging.items,
},
{
type: "page",
name: `🗂️ ${fileIdentity.heading}`,
items: [this.createRebuildRequiredAction(), ...(fileIdentity.items ?? [])],
},
this.createExtraMenusPage(),
]);
}
@@ -1128,7 +1133,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
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."
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first."
),
LOG_LEVEL_NOTICE
);
@@ -581,11 +581,11 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
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."
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first."
);
expect(loggerMocks.Logger).toHaveBeenCalledWith(
expect.stringContaining(
"Rebuild both databases from a complete authoritative Vault and Fetch on the other devices."
"Rebuild from a complete authoritative Vault and Fetch on the other devices."
),
LOG_LEVEL_NOTICE
);
@@ -692,6 +692,30 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
return { tab, saveSettingData };
}
it("includes path identity once in the declarative General settings group", () => {
const { tab } = createSettingsTab();
const group = (
tab as unknown as {
createGeneralSettingsGroup: () => {
items: Array<{
name: string;
items?: Array<{ control?: { key: string }; action?: () => void; visible?: () => boolean }>;
}>;
};
}
).createGeneralSettingsGroup();
const fileIdentity = group.items.find(({ name }) => name === "🗂️ File identity");
expect(fileIdentity).toBeDefined();
const apply = fileIdentity?.items?.find(({ action }) => typeof action === "function");
expect(apply).toBeDefined();
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
expect(apply?.visible?.()).toBe(true);
expect(fileIdentity?.items?.flatMap(({ control }) => (control ? [control.key] : []))).toEqual([
"pathNormalizationVersion",
]);
});
it("awaits the stored passphrase before initialising the editing buffer", async () => {
const { tab } = createSettingsTab();
tab.resetEditingSettings();
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
};
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"Memory cache",
@@ -56,7 +56,6 @@ describe("paneAdvanced", () => {
panelElements.get("Local Database Tweak"),
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"),
@@ -69,7 +68,6 @@ describe("paneAdvanced", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { paneGeneral } from "./PaneGeneral.ts";
const settingHarness = vi.hoisted(() => ({
rendered: [] as unknown[],
}));
vi.mock("@/common/translation", () => ({
$msg: (key: string) => key,
$t: (key: string) => key,
}));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class LiveSyncSetting {},
}));
vi.mock("./SettingSpec.ts", async (importOriginal) => {
const original = await importOriginal<typeof import("./SettingSpec.ts")>();
return {
...original,
renderLegacySettingSpec: vi.fn((_renderer: unknown, spec: unknown) => {
settingHarness.rendered.push(spec);
}),
};
});
afterEach(() => {
settingHarness.rendered.length = 0;
vi.clearAllMocks();
});
describe("paneGeneral", () => {
it("renders path identity once in the File identity panel and preserves its held value", async () => {
const addPanel = vi.fn((_parent: HTMLElement, heading: string) =>
Promise.resolve({ heading } as unknown as HTMLElement)
);
const host = {
isConfiguredAs: vi.fn(() => false),
};
paneGeneral.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(14));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"obsidianLiveSyncSettingTab.titleAppearance",
"obsidianLiveSyncSettingTab.titleLogging",
"File identity",
"obsidianLiveSyncSettingTab.titleExtraMenus",
]);
const pathIdentity = settingHarness.rendered.filter(
(spec) => (spec as { key: string }).key === "pathNormalizationVersion"
);
expect(pathIdentity).toHaveLength(1);
expect(pathIdentity[0]).toMatchObject({ holdValue: true });
expect((pathIdentity[0] as { visible?: () => boolean }).visible).toBeUndefined();
});
});
@@ -17,7 +17,7 @@ const rangeMessages = {
};
describe("Advanced setting specifications", () => {
it("lists only the eleven 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([
@@ -31,7 +31,6 @@ describe("Advanced setting specifications", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -52,13 +51,12 @@ describe("Advanced setting specifications", () => {
expect(spec.disabled).toBeUndefined();
});
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
it("keeps 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",
@@ -70,18 +68,67 @@ 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 })
it("excludes transient settings-dialogue keys from the persisted key type", () => {
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
});
it("resolves translated Commonlib metadata for every explicitly exposed key", () => {
const specs = [
...createAdvancedSettingSpecGroups({ isCouchDB: () => true }),
...createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
}),
].flatMap(({ items }) => items);
for (const spec of specs) {
const metadata = getConfig(spec.key);
expect(metadata, spec.key).not.toBe(false);
if (!metadata) {
throw new Error(`Missing setting metadata for ${spec.key}`);
}
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
expect(definition.name).toBe(`${metadata.name}${statusDisplay(metadata.status)}`);
}
});
});
describe("General setting specifications", () => {
it("shares every General, Logging, and File identity control between the imperative and declarative renderers", () => {
const groups = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging", "File identity"]);
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
"displayLanguage",
"showStatusOnEditor",
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
"pathNormalizationVersion",
]);
});
it("exposes path identity for every remote type as a reconstruction-required dropdown", () => {
const spec = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => false,
showVerboseLog: () => false,
})
.flatMap(({ items }) => items)
.find(({ key }) => key === "pathNormalizationVersion");
if (!spec) throw new Error("Path identity mode is not exposed on the Advanced page");
if (!spec) throw new Error("Path identity mode is not exposed in General settings");
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).toMatchObject({ key: "pathNormalizationVersion", holdValue: true });
expect(spec.visible).toBeUndefined();
expect(spec.control.options()).toEqual({
"0": "Legacy path identity",
"1": "Unicode NFC path identity",
@@ -102,54 +149,11 @@ describe("Advanced setting specifications", () => {
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>();
});
it("resolves translated Commonlib metadata for every explicitly exposed key", () => {
const specs = createAdvancedSettingSpecGroups({ isCouchDB: () => true }).flatMap(({ items }) => items);
for (const spec of specs) {
const metadata = getConfig(spec.key);
expect(metadata, spec.key).not.toBe(false);
if (!metadata) {
throw new Error(`Missing setting metadata for ${spec.key}`);
}
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
expect(definition.name).toBe(`${metadata.name}${statusDisplay(metadata.status)}`);
}
});
});
describe("General setting specifications", () => {
it("shares every General and Logging control between the imperative and declarative renderers", () => {
const groups = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging"]);
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
"displayLanguage",
"showStatusOnEditor",
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
]);
});
it("keeps the three feature-level controls together under Extra menus", () => {
const group = createExtraMenuSettingSpecGroup();
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("@/common/translation", () => ({
$msg: (key: string) => key,
$t: (key: string) => key,
translateLiveSyncMessage: (key: string) => key,
}));
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() }));
@@ -20,6 +21,7 @@ vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
import {
createAdvancedSettingDefinitionGroups,
createGeneralSettingDefinitionGroups,
createSettingsPageCatalogue,
getSettingsRootGroupEntry,
type SettingsRootGroupId,
@@ -87,7 +89,6 @@ describe("settings page catalogue", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -97,4 +98,22 @@ describe("settings page catalogue", () => {
]);
expect(new Set(keys).size).toBe(keys.length);
});
it("registers path identity once in General settings and leaves it out of Advanced", () => {
const generalGroups = createGeneralSettingDefinitionGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
const generalKeys = generalGroups.flatMap(({ items = [] }) =>
items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
);
const advancedKeys = createAdvancedSettingDefinitionGroups({ isCouchDB: () => true }).flatMap(
({ items = [] }) =>
items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
);
expect(generalGroups.map(({ heading }) => heading)).toContain("File identity");
expect(generalKeys.filter((key) => key === "pathNormalizationVersion")).toHaveLength(1);
expect(advancedKeys).not.toContain("pathNormalizationVersion");
});
});