Merge prerequisite changes into Japanese localisation

This commit is contained in:
vorotamoroz committed 2026-11-05 10:03:28 +00:00
commit 49120cf5e0
98 files changed
+8487 -1233

No files matched your search

@@ -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(
"then 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();
});
});
@@ -8,6 +8,8 @@ import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/comm
import { FlagFilesHumanReadable, FlagFilesOriginal } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { isLiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import type { StoppedJournal } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
import { visibleOnly, type PageFunctions } from "./SettingPane";
@@ -151,6 +153,80 @@ export function paneMaintenance(
)
.addOnUpdate(this.onlyOnCouchDB);
// Offer manual recovery after Journal receipt stops on a failure. Normal retries
// revisit the same Journal; this action confirms and skips only that Journal,
// resumes receipt, and still stops at the next Journal failure.
new Setting(paneEl)
.setName($msg("Skip stopped Journal and resume"))
.setDesc(
$msg(
"Object Storage only. Confirm to skip the currently stopped Journal and resume replication. The confirmation names the exact Journal key and failure stage. Skipping may leave content or history missing; the next Journal failure still stops replication."
)
)
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Skip and resume"))
.setDisabled(false)
.onClick(async () => {
const replicator = this.core.replicator;
if (!isLiveSyncJournalReplicator(replicator)) {
Logger(
$msg(
"No stopped Journal is available. Retry replication to identify the stopped Journal."
),
LOG_LEVEL_NOTICE
);
return;
}
const stoppedJournal: Readonly<StoppedJournal> | undefined = replicator.getStoppedJournal();
if (!stoppedJournal) {
Logger(
$msg(
"No stopped Journal is available. Retry replication to identify the stopped Journal."
),
LOG_LEVEL_NOTICE
);
return;
}
const confirmed =
(await this.core.confirm.askYesNoDialog(
$msg(
"Skip stopped Journal '${KEY}' at failure stage '${STAGE}' and resume replication? This may leave content or history missing.",
{ KEY: stoppedJournal.key, STAGE: stoppedJournal.stage }
),
{
title: $msg("Skip stopped Journal and resume"),
defaultOption: "No",
}
)) === "yes";
if (!confirmed) {
return;
}
if (this.core.replicator !== replicator) {
Logger(
$msg(
"The active replicator changed. Retry replication to identify the stopped Journal."
),
LOG_LEVEL_NOTICE
);
return;
}
const resumed = await replicator.skipStoppedJournalAndResume(
this.core.settings,
stoppedJournal
);
if (!resumed) {
Logger(
$msg(
"Journal receipt did not complete after this action. Check the current failure before retrying."
),
LOG_LEVEL_NOTICE
);
}
})
)
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName($msg("Reset journal received history"))
.setDesc(
@@ -1,8 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const maintenanceHarness = vi.hoisted(() => ({
createdSettings: [] as Array<{ name: string; click?: () => Promise<void> }>,
createdSettings: [] as Array<{
name: string;
click?: () => Promise<void>;
toggleKey?: string;
onUpdate?: () => { visibility?: boolean };
}>,
logger: vi.fn(),
notice: vi.fn(),
}));
vi.mock("@/common/events.ts", () => ({
@@ -10,7 +16,19 @@ vi.mock("@/common/events.ts", () => ({
eventHub: { emitEvent: vi.fn() },
}));
vi.mock("@/common/translation", () => ({
$msg: (message: string) => message,
$msg: (message: string, replacements?: Record<string, unknown>) => {
for (const [key, value] of Object.entries(replacements ?? {})) {
message = message.split(String.fromCharCode(36) + "{" + key + "}").join(String(value));
}
return message;
},
}));
vi.mock("@/deps.ts", () => ({
Notice: class {
constructor(message: string) {
maintenanceHarness.notice(message);
}
},
}));
vi.mock("@/serviceFeatures/setupObsidian/settingsReset.ts", () => ({
createCoreSettingsAfterFullReset: vi.fn(),
@@ -33,10 +51,19 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", () => ({
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
LiveSyncCouchDBReplicator: class {},
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => {
class LiveSyncJournalReplicator {}
return {
LiveSyncJournalReplicator,
isLiveSyncJournalReplicator: (value: unknown) => value instanceof LiveSyncJournalReplicator,
};
});
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class {
name = "";
click?: () => Promise<void>;
toggleKey?: string;
onUpdate?: () => { visibility?: boolean };
constructor() {
maintenanceHarness.createdSettings.push(this);
@@ -51,6 +78,11 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this;
}
autoWireToggle(key: string) {
this.toggleKey = key;
return this;
}
addButton(callback: (button: this) => void) {
callback(this);
return this;
@@ -73,7 +105,8 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this;
}
addOnUpdate() {
addOnUpdate(onUpdate: () => { visibility?: boolean }) {
this.onUpdate = onUpdate;
return this;
}
},
@@ -85,14 +118,61 @@ vi.mock("./settingComponentStyles.ts", () => ({
setButtonDestructiveState: <T>(button: T) => button,
}));
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { paneMaintenance } from "./PaneMaintenance.ts";
afterEach(() => {
maintenanceHarness.createdSettings.length = 0;
maintenanceHarness.logger.mockClear();
maintenanceHarness.notice.mockClear();
vi.clearAllMocks();
});
type TestStoppedJournal = Readonly<{ key: string; stage: string }>;
type ConfirmationOptions = { title: string; defaultOption: "No" };
type AskYesNoDialog = (message: string, options: ConfirmationOptions) => Promise<"yes" | "no">;
function createJournalReplicator(stoppedJournal: TestStoppedJournal | undefined, result = true) {
const getStoppedJournal = vi.fn(() => stoppedJournal);
const skipStoppedJournalAndResume = vi.fn(async (_settings: unknown, _snapshot: unknown) => result);
const replicator = Object.assign(new LiveSyncJournalReplicator({} as never), {
getStoppedJournal,
skipStoppedJournalAndResume,
});
return { replicator, getStoppedJournal, skipStoppedJournalAndResume };
}
function createSyncingPane(replicator: unknown, askYesNoDialog: AskYesNoDialog = async () => "no") {
const settings = { databaseName: "test-database" };
const onlyOnMinIO = vi.fn(() => ({ visibility: true }));
const confirmation = vi.fn(askYesNoDialog);
const host = {
core: { replicator, settings, confirm: { askYesNoDialog: confirmation } },
createEl: vi.fn(),
getMinioJournalSyncClient: vi.fn(),
onlyOnCouchDB: vi.fn(),
onlyOnCouchDBOrMinIO: vi.fn(),
onlyOnMinIO,
services: { replication: { markLocked: vi.fn(), markUnlocked: vi.fn() } },
};
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
then(callback: (paneEl: HTMLElement) => void) {
if (heading === "Syncing") {
callback({} as HTMLElement);
}
return Promise.resolve();
},
}));
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
const setting = maintenanceHarness.createdSettings.find(({ name }) => name === "Skip stopped Journal and resume");
if (!setting?.click) {
throw new Error("Skip stopped Journal action was not registered");
}
return { setting, click: setting.click, host, settings, askYesNoDialog: confirmation, onlyOnMinIO };
}
describe("paneMaintenance", () => {
it("does not announce success when the remote wipe reports failure", async () => {
const updateCheckPointInfo = vi.fn(async () => undefined);
@@ -180,9 +260,145 @@ describe("paneMaintenance", () => {
expect(resetDatabase).toHaveBeenCalledOnce();
expect(initialiseDatabase).toHaveBeenCalledOnce();
expect(maintenanceHarness.logger).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", "notice");
});
it("removes the broken journal toggle and preserves received history reset semantics", async () => {
const checkpoint = {
receivedFiles: new Set(["received-file"]),
knownIDs: new Set(["known-id"]),
sentIDs: new Set(["sent-id"]),
sentFiles: new Set(["sent-file"]),
lastLocalSeq: 23,
};
let updatedCheckpoint: typeof checkpoint | undefined;
const updateCheckPointInfo = vi.fn(async (update: (info: typeof checkpoint) => typeof checkpoint) => {
updatedCheckpoint = update(checkpoint);
});
const onlyOnMinIO = vi.fn(() => ({ visibility: true }));
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
then(callback: (paneEl: HTMLElement) => void) {
if (heading === "Syncing") {
callback({} as HTMLElement);
}
return Promise.resolve();
},
}));
const host = {
core: { replicator: {}, storageAccess: {} },
createEl: vi.fn(),
getMinioJournalSyncClient: vi.fn(() => ({ updateCheckPointInfo })),
onlyOnCouchDB: vi.fn(),
onlyOnCouchDBOrMinIO: vi.fn(),
onlyOnMinIO,
services: { replication: { markLocked: vi.fn(), markUnlocked: vi.fn() } },
};
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
expect(maintenanceHarness.createdSettings.some(({ toggleKey }) => toggleKey === "ignoreBrokenJournals")).toBe(
false
);
const resetReceivedHistory = maintenanceHarness.createdSettings.find(
({ name }) => name === "Reset journal received history"
);
if (!resetReceivedHistory?.click) {
throw new Error("Reset journal received history action was not registered");
}
await resetReceivedHistory.click();
expect(updateCheckPointInfo).toHaveBeenCalledOnce();
expect(updatedCheckpoint).toEqual({
...checkpoint,
receivedFiles: new Set(),
knownIDs: new Set(),
});
expect(maintenanceHarness.logger).toHaveBeenCalledWith("Journal received history has been cleared.", "notice");
});
it("asks the user to retry when there is no stopped Journal", async () => {
const { replicator, getStoppedJournal, skipStoppedJournalAndResume } = createJournalReplicator(undefined);
const rendered = createSyncingPane(replicator);
expect(rendered.setting.onUpdate).toBe(rendered.onlyOnMinIO);
await rendered.click();
expect(getStoppedJournal).toHaveBeenCalledOnce();
expect(rendered.askYesNoDialog).not.toHaveBeenCalled();
expect(skipStoppedJournalAndResume).not.toHaveBeenCalled();
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
"Ui.Common.LocalDatabaseInitialisationFailed",
"No stopped Journal is available. Retry replication to identify the stopped Journal.",
"notice"
);
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
});
it("does not skip the stopped Journal when confirmation is cancelled", async () => {
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal);
const rendered = createSyncingPane(replicator);
await rendered.click();
expect(rendered.askYesNoDialog).toHaveBeenCalledOnce();
const call = rendered.askYesNoDialog.mock.calls[0];
if (!call) {
throw new Error("Stopped Journal confirmation was not requested");
}
const [message, options] = call;
expect(message).toContain(stoppedJournal.key);
expect(message).toContain(stoppedJournal.stage);
expect(options).toEqual({ title: "Skip stopped Journal and resume", defaultOption: "No" });
expect(skipStoppedJournalAndResume).not.toHaveBeenCalled();
});
it("skips and resumes with the exact confirmed stopped Journal snapshot", async () => {
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal);
const confirm = vi.fn(async () => "yes" as const);
const rendered = createSyncingPane(replicator, confirm);
await rendered.click();
expect(skipStoppedJournalAndResume).toHaveBeenCalledOnce();
expect(skipStoppedJournalAndResume.mock.calls[0]?.[0]).toBe(rendered.settings);
expect(skipStoppedJournalAndResume.mock.calls[0]?.[1]).toBe(stoppedJournal);
});
it("does not skip after the active replicator changes during confirmation", async () => {
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
const active = createJournalReplicator(stoppedJournal);
const replacement = createJournalReplicator(stoppedJournal);
const confirm = vi.fn(async () => "yes" as const);
const rendered = createSyncingPane(active.replicator, confirm);
confirm.mockImplementation(async () => {
rendered.host.core.replicator = replacement.replicator;
return "yes";
});
await rendered.click();
expect(active.skipStoppedJournalAndResume).not.toHaveBeenCalled();
expect(replacement.skipStoppedJournalAndResume).not.toHaveBeenCalled();
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
"The active replicator changed. Retry replication to identify the stopped Journal.",
"notice"
);
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
});
it("reports when receipt does not complete after skip and resume", async () => {
const stoppedJournal = { key: "journal/one", stage: "save-records" } as const;
const { replicator, skipStoppedJournalAndResume } = createJournalReplicator(stoppedJournal, false);
const rendered = createSyncingPane(replicator, async () => "yes");
await rendered.click();
expect(skipStoppedJournalAndResume).toHaveBeenCalledOnce();
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
"Journal receipt did not complete after this action. Check the current failure before retrying.",
"notice"
);
expect(maintenanceHarness.notice).not.toHaveBeenCalled();
});
});
@@ -28,6 +28,7 @@ export function panePowerUsers(
onUpdate: this.onlyOnCouchDB,
});
new Setting(paneEl).autoWireToggle("useTimeouts", { onUpdate: this.onlyOnCouchDB });
new Setting(paneEl).autoWireToggle("useCouchDBCacheControlHeadersOnly", { onUpdate: this.onlyOnCouchDB });
});
void addPanel(paneEl, $msg("Configuration Encryption")).then((paneEl) => {
const passphrase_options: Record<ConfigPassphraseStore, string> = {
@@ -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");
});
});