Introduce shared setting specifications for Advanced settings

This commit is contained in:
vorotamoroz
2026-08-24 10:36:39 +00:00
parent ebaf89f822
commit d8f7762d01
8 changed files with 618 additions and 50 deletions
@@ -0,0 +1,50 @@
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SettingSpecGroup } from "./SettingSpec.ts";
export type AdvancedSettingSpecContext = {
isCouchDB: () => boolean;
};
/** Build the explicitly exposed standard controls for the existing Advanced page. */
export function createAdvancedSettingSpecGroups({
isCouchDB,
}: AdvancedSettingSpecContext): readonly SettingSpecGroup[] {
return [
{
heading: "Memory cache",
items: [{ key: "hashCacheMaxCount", control: { type: "number", min: 10 } }],
},
{
heading: "Local Database Tweak",
items: [
{
key: "chunkSplitterVersion",
control: { type: "dropdown", options: () => ChunkAlgorithmNames },
},
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
],
},
{
heading: "Transfer Tweak",
items: [
{ key: "readChunksOnline", control: { type: "toggle" }, visible: isCouchDB },
{ key: "useOnlyLocalChunk", control: { type: "toggle" }, visible: isCouchDB },
{
key: "concurrencyOfReadChunksOnline",
control: { type: "number", min: 10 },
visible: isCouchDB,
},
{
key: "minimumIntervalOfReadChunksOnline",
control: { type: "number", min: 10 },
visible: isCouchDB,
},
{ key: "autoAcceptCompatibleTweak", control: { type: "toggle", defaultValue: true } },
],
},
{
heading: "Remote Database Tweak",
items: [{ key: "enableCompression", control: { type: "toggle" } }],
},
];
}
@@ -1,43 +1,18 @@
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
import { createAdvancedSettingSpecGroups } from "./AdvancedSettingSpecs.ts";
import { renderLegacySettingSpec } from "./SettingSpec.ts";
export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
void addPanel(paneEl, "Memory cache").then((paneEl) => {
new Setting(paneEl).autoWireNumeric("hashCacheMaxCount", { clampMin: 10 });
// new Setting(paneEl).autoWireNumeric("hashCacheMaxAmount", { clampMin: 1 });
const groups = createAdvancedSettingSpecGroups({
isCouchDB: () => this.onlyOnCouchDB().visibility !== false,
});
void addPanel(paneEl, "Local Database Tweak").then((paneEl) => {
const items = ChunkAlgorithmNames;
new Setting(paneEl).autoWireDropDown("chunkSplitterVersion", {
options: items,
for (const group of groups) {
void addPanel(paneEl, group.heading).then((panelEl) => {
for (const spec of group.items) {
renderLegacySettingSpec(new Setting(panelEl), spec);
}
});
new Setting(paneEl).autoWireNumeric("customChunkSize", { clampMin: 0, acceptZero: true });
});
void addPanel(paneEl, "Transfer Tweak").then((paneEl) => {
new Setting(paneEl).autoWireToggle("readChunksOnline", { onUpdate: this.onlyOnCouchDB });
new Setting(paneEl).autoWireToggle("useOnlyLocalChunk", { onUpdate: this.onlyOnCouchDB });
new Setting(paneEl).autoWireNumeric("concurrencyOfReadChunksOnline", {
clampMin: 10,
onUpdate: this.onlyOnCouchDB,
});
new Setting(paneEl).autoWireNumeric("minimumIntervalOfReadChunksOnline", {
clampMin: 10,
onUpdate: this.onlyOnCouchDB,
});
new Setting(paneEl).autoWireToggle("autoAcceptCompatibleTweak", { defaultToggleValue: true });
// new Setting(paneEl)
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })
// new Setting(paneEl)
// .autoWireNumeric("sendChunksBulkMaxSize", {
// clampMax: 100, clampMin: 1, onUpdate: onlyOnCouchDB
// })
});
void addPanel(paneEl, "Remote Database Tweak").then((paneEl) => {
new Setting(paneEl).autoWireToggle("enableCompression");
});
}
}
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { paneAdvanced } from "./PaneAdvanced.ts";
const settingHarness = vi.hoisted(() => ({
createdIn: [] as HTMLElement[],
rendered: [] as unknown[],
}));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class LiveSyncSetting {
constructor(containerEl: HTMLElement) {
settingHarness.createdIn.push(containerEl);
}
},
}));
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.createdIn.length = 0;
settingHarness.rendered.length = 0;
vi.clearAllMocks();
});
describe("paneAdvanced", () => {
it("renders the shared specifications into the four existing panels", async () => {
const panelElements = new Map<string, HTMLElement>();
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => {
const panel = { heading } as unknown as HTMLElement;
panelElements.set(heading, panel);
return Promise.resolve(panel);
});
const host = {
onlyOnCouchDB: vi.fn(() => ({ visibility: false })),
};
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(9));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"Memory cache",
"Local Database Tweak",
"Transfer Tweak",
"Remote Database Tweak",
]);
expect(settingHarness.createdIn).toEqual([
panelElements.get("Memory cache"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Remote Database Tweak"),
]);
expect(settingHarness.rendered.map((spec) => (spec as { key: string }).key)).toEqual([
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
"minimumIntervalOfReadChunksOnline",
"autoAcceptCompatibleTweak",
"enableCompression",
]);
const readChunksOnline = settingHarness.rendered.find(
(spec) => (spec as { key: string }).key === "readChunksOnline"
) as { visible: () => boolean };
expect(readChunksOnline.visible()).toBe(false);
expect(host.onlyOnCouchDB).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,237 @@
import { statusDisplay, type ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SettingControl, SettingDefinitionControl } from "obsidian";
import type { AutoWireOption, OnUpdateResult } from "./SettingPane.ts";
import type { AllBooleanItemKey, AllNumericItemKey, AllStringItemKey, OnDialogSettings } from "./settingConstants.ts";
export type PersistedBooleanSettingKey = Exclude<AllBooleanItemKey, keyof OnDialogSettings>;
export type PersistedStringSettingKey = Exclude<AllStringItemKey, keyof OnDialogSettings>;
export type PersistedNumericSettingKey = Exclude<AllNumericItemKey, keyof OnDialogSettings>;
export type PersistedSettingKey = PersistedBooleanSettingKey | PersistedStringSettingKey | PersistedNumericSettingKey;
type SettingSpecBase<K extends PersistedSettingKey, C> = {
key: K;
control: C;
visible?: () => boolean;
disabled?: () => boolean;
aliases?: string[];
};
type ToggleSettingSpec = SettingSpecBase<
PersistedBooleanSettingKey,
{
type: "toggle";
defaultValue?: boolean;
}
>;
type NumberSettingSpec = SettingSpecBase<
PersistedNumericSettingKey,
{
type: "number";
min?: number;
max?: number;
allowZero?: boolean;
}
>;
type DropdownSettingSpec = SettingSpecBase<
PersistedStringSettingKey,
{
type: "dropdown";
options: () => Record<string, string>;
}
>;
export type SettingSpec = ToggleSettingSpec | NumberSettingSpec | DropdownSettingSpec;
/** An existing Advanced-page panel and the standard controls rendered within it. */
export type SettingSpecGroup = {
heading: string;
items: readonly SettingSpec[];
};
export type SettingSpecMessages = {
valueShouldBeInRange: (range: { min?: number; max?: number }) => string;
};
type LegacySettingOptions = Pick<AutoWireOption, "onUpdate">;
export type LegacySettingBinding =
| {
type: "toggle";
key: PersistedBooleanSettingKey;
options: LegacySettingOptions & Pick<AutoWireOption, "defaultToggleValue">;
}
| {
type: "number";
key: PersistedNumericSettingKey;
options: LegacySettingOptions & {
clampMin?: number;
clampMax?: number;
acceptZero?: boolean;
};
}
| {
type: "dropdown";
key: PersistedStringSettingKey;
options: LegacySettingOptions & { options: Record<string, string> };
};
export interface LegacySettingSpecRenderer {
autoWireToggle(key: AllBooleanItemKey, options?: AutoWireOption): unknown;
autoWireNumeric(
key: AllNumericItemKey,
options: AutoWireOption & { clampMin?: number; clampMax?: number; acceptZero?: boolean }
): unknown;
autoWireDropDown(key: AllStringItemKey, options: AutoWireOption & { options: Record<string, string> }): unknown;
}
function isToggleSettingSpec(spec: SettingSpec): spec is ToggleSettingSpec {
return spec.control.type === "toggle";
}
function isNumberSettingSpec(spec: SettingSpec): spec is NumberSettingSpec {
return spec.control.type === "number";
}
function toLegacyUpdateOptions(spec: SettingSpec): LegacySettingOptions {
if (!spec.visible && !spec.disabled) {
return {};
}
return {
onUpdate: (): OnUpdateResult => ({
...(spec.visible ? { visibility: spec.visible() } : {}),
...(spec.disabled ? { disabled: spec.disabled() } : {}),
}),
};
}
/** Convert a shared specification to the arguments accepted by the current AutoWire renderer. */
export function toLegacySettingBinding(spec: SettingSpec): LegacySettingBinding {
const updateOptions = toLegacyUpdateOptions(spec);
if (isToggleSettingSpec(spec)) {
return {
type: "toggle",
key: spec.key,
options: {
...updateOptions,
...(spec.control.defaultValue === undefined ? {} : { defaultToggleValue: spec.control.defaultValue }),
},
};
}
if (isNumberSettingSpec(spec)) {
return {
type: "number",
key: spec.key,
options: {
...updateOptions,
...(spec.control.min === undefined ? {} : { clampMin: spec.control.min }),
...(spec.control.max === undefined ? {} : { clampMax: spec.control.max }),
...(spec.control.allowZero === undefined ? {} : { acceptZero: spec.control.allowZero }),
},
};
}
return {
type: "dropdown",
key: spec.key,
options: {
...updateOptions,
options: spec.control.options(),
},
};
}
/** Render one shared specification without moving value or persistence ownership out of the settings tab. */
export function renderLegacySettingSpec(renderer: LegacySettingSpecRenderer, spec: SettingSpec): void {
const binding = toLegacySettingBinding(spec);
switch (binding.type) {
case "toggle":
renderer.autoWireToggle(binding.key, binding.options);
return;
case "number":
renderer.autoWireNumeric(binding.key, binding.options);
return;
case "dropdown":
renderer.autoWireDropDown(binding.key, binding.options);
}
}
function numberIsOutOfRange(value: number, control: NumberSettingSpec["control"]) {
if (!Number.isFinite(value)) {
return true;
}
if (control.max !== undefined && value > control.max) {
return true;
}
if (control.allowZero && value === 0) {
return false;
}
return control.min !== undefined && value < control.min;
}
/**
* Convert one shared specification to a declarative Obsidian control.
*
* This function returns a plain object and only refers to Obsidian through erased types, so Stage B does not load or
* activate the 1.13 runtime API. Translated metadata and validation messages are explicit inputs to keep conversion
* side-effect free.
*/
export function toObsidianSettingDefinition(
spec: SettingSpec,
metadata: Pick<ConfigurationItem, "name" | "desc" | "placeHolder" | "status">,
messages: SettingSpecMessages
): SettingDefinitionControl<PersistedSettingKey> {
let control: SettingControl<PersistedSettingKey>;
switch (spec.control.type) {
case "toggle":
control = {
type: "toggle",
key: spec.key,
...(spec.control.defaultValue === undefined ? {} : { defaultValue: spec.control.defaultValue }),
...(spec.disabled ? { disabled: spec.disabled } : {}),
};
break;
case "number": {
const numericControl = spec.control;
const hasRange = numericControl.min !== undefined || numericControl.max !== undefined;
control = {
type: "number",
key: spec.key,
...(numericControl.min === undefined
? {}
: { min: numericControl.allowZero ? Math.min(0, numericControl.min) : numericControl.min }),
...(numericControl.max === undefined ? {} : { max: numericControl.max }),
...(metadata.placeHolder ? { placeholder: metadata.placeHolder } : {}),
...(hasRange
? {
validate: (value: number) =>
numberIsOutOfRange(value, numericControl)
? messages.valueShouldBeInRange({
min: numericControl.min,
max: numericControl.max,
})
: undefined,
}
: {}),
...(spec.disabled ? { disabled: spec.disabled } : {}),
};
break;
}
case "dropdown":
control = {
type: "dropdown",
key: spec.key,
options: spec.control.options(),
...(spec.disabled ? { disabled: spec.disabled } : {}),
};
break;
}
return {
name: `${metadata.name}${statusDisplay(metadata.status)}`,
...(metadata.desc ? { desc: metadata.desc } : {}),
...(spec.aliases ? { aliases: spec.aliases } : {}),
...(spec.visible ? { visible: spec.visible } : {}),
control,
};
}
@@ -0,0 +1,199 @@
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 {
renderLegacySettingSpec,
toLegacySettingBinding,
toObsidianSettingDefinition,
type PersistedSettingKey,
type SettingSpec,
} from "./SettingSpec.ts";
import { createAdvancedSettingSpecGroups } from "./AdvancedSettingSpecs.ts";
const rangeMessages = {
valueShouldBeInRange: ({ min, max }: { min?: number; max?: number }) => `${min ?? "~"}..${max ?? "~"}`,
};
describe("Advanced setting specifications", () => {
it("lists only the nine controls currently exposed by the Advanced page", () => {
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
expect(groups.map(({ heading }) => heading)).toEqual([
"Memory cache",
"Local Database Tweak",
"Transfer Tweak",
"Remote Database Tweak",
]);
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
"hashCacheMaxCount",
"chunkSplitterVersion",
"customChunkSize",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
"minimumIntervalOfReadChunksOnline",
"autoAcceptCompatibleTweak",
"enableCompression",
]);
});
it("keeps CouchDB visibility on the four remote chunk controls", () => {
let couchDB = false;
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
const conditional = groups.flatMap(({ items }) => items).filter(({ visible }) => visible !== undefined);
expect(conditional.map(({ key }) => key)).toEqual([
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
"minimumIntervalOfReadChunksOnline",
]);
expect(conditional.every(({ visible }) => visible?.() === false)).toBe(true);
couchDB = true;
expect(conditional.every(({ visible }) => visible?.() === true)).toBe(true);
});
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("SettingSpec conversion", () => {
it("maps metadata and a toggle to an Obsidian definition without importing the runtime API", () => {
const visible = vi.fn(() => true);
const disabled = vi.fn(() => false);
const spec: SettingSpec = {
key: "autoAcceptCompatibleTweak",
control: { type: "toggle", defaultValue: true },
aliases: ["compatible tweaks"],
visible,
disabled,
};
const metadata = {
name: "Automatic compatibility",
desc: "Accept compatible values.",
status: "BETA",
} satisfies ConfigurationItem;
expect(toObsidianSettingDefinition(spec, metadata, rangeMessages)).toEqual({
name: `Automatic compatibility${statusDisplay("BETA")}`,
desc: "Accept compatible values.",
aliases: ["compatible tweaks"],
visible,
control: {
type: "toggle",
key: "autoAcceptCompatibleTweak",
defaultValue: true,
disabled,
},
});
});
it("maps dropdown options to both native and legacy representations", () => {
const options = {
"v3-rabin-karp": "V3",
legacy: "Legacy",
};
const spec: SettingSpec = {
key: "chunkSplitterVersion",
control: { type: "dropdown", options: () => options },
};
const metadata = {
name: "Chunk splitter",
placeHolder: "Select a splitter",
} satisfies ConfigurationItem;
expect(toObsidianSettingDefinition(spec, metadata, rangeMessages).control).toEqual({
type: "dropdown",
key: "chunkSplitterVersion",
options,
});
expect(toLegacySettingBinding(spec)).toEqual({
type: "dropdown",
key: "chunkSplitterVersion",
options: { options },
});
});
it("maps number limits and zero exceptions to equivalent validation", () => {
const spec: SettingSpec = {
key: "hashCacheMaxCount",
control: { type: "number", min: 10, max: 100, allowZero: true },
};
const metadata = { name: "Cache size" } satisfies ConfigurationItem;
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
const control = definition.control;
expect(control).toMatchObject({
type: "number",
key: "hashCacheMaxCount",
min: 0,
max: 100,
});
if (control.type !== "number") {
throw new Error("Expected a number control");
}
expect(control.validate?.(0)).toBeUndefined();
expect(control.validate?.(10)).toBeUndefined();
expect(control.validate?.(5)).toBe("10..100");
expect(control.validate?.(101)).toBe("10..100");
expect(toLegacySettingBinding(spec)).toEqual({
type: "number",
key: "hashCacheMaxCount",
options: { clampMin: 10, clampMax: 100, acceptZero: true },
});
});
it("checks the maximum before applying a zero exception, as the legacy renderer does", () => {
const spec: SettingSpec = {
key: "hashCacheMaxCount",
control: { type: "number", max: -1, allowZero: true },
};
const definition = toObsidianSettingDefinition(spec, { name: "Cache size" }, rangeMessages);
const control = definition.control;
if (control.type !== "number") {
throw new Error("Expected a number control");
}
expect(control.validate?.(0)).toBe("~..-1");
});
it("dispatches a specification through the existing AutoWire renderer", () => {
const renderer = {
autoWireToggle: vi.fn(),
autoWireNumeric: vi.fn(),
autoWireDropDown: vi.fn(),
};
const visible = vi.fn(() => false);
const spec: SettingSpec = {
key: "readChunksOnline",
control: { type: "toggle" },
visible,
};
renderLegacySettingSpec(renderer, spec);
expect(renderer.autoWireToggle).toHaveBeenCalledOnce();
const [, options] = renderer.autoWireToggle.mock.calls[0];
expect(options.onUpdate()).toEqual({ visibility: false });
expect(visible).toHaveBeenCalledOnce();
expect(renderer.autoWireNumeric).not.toHaveBeenCalled();
expect(renderer.autoWireDropDown).not.toHaveBeenCalled();
});
});