feat(settings): settings import/export and WebView2 clipboard history
This commit is contained in:
1 parent
d44798f70f
commit
7961e688f2
19 files changed
+2316
-216
No files matched your search
@@ -1,6 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, editorSettingsDraftChanged, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, shouldConfirmEditorSettingsDialogClose } from "../editorSettingsDraft";
|
||||
import {
|
||||
EDITOR_SETTINGS_DRAFT_KEYS,
|
||||
editorSettingsDraftFromSettings,
|
||||
editorSettingsDraftChanged,
|
||||
editorSettingsDraftPatchFromSettings,
|
||||
editorSettingsPatchFromDraft,
|
||||
normalizeQueryResultMaxRowsDraft,
|
||||
normalizeTableOpenPageSizeDraft,
|
||||
shouldConfirmEditorSettingsDialogClose,
|
||||
} from "../editorSettingsDraft";
|
||||
import type { EditorSettings } from "@/stores/settingsStore";
|
||||
|
||||
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
@@ -468,3 +477,26 @@ describe("editorSettingsPatchFromDraft - tabLayout", () => {
|
||||
expect(patch.tabLayout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorSettingsDraftPatchFromSettings", () => {
|
||||
it("contains exactly the keys present in the input", () => {
|
||||
const patch = editorSettingsDraftPatchFromSettings({ wordWrap: true, pageSize: 200 } as Partial<EditorSettings>);
|
||||
expect(Object.keys(patch).sort()).toEqual(["pageSize", "wordWrap"]);
|
||||
expect(patch.wordWrap).toBe(true);
|
||||
expect(patch.pageSize).toBe(200);
|
||||
});
|
||||
|
||||
it("normalizes imported values per key", () => {
|
||||
const patch = editorSettingsDraftPatchFromSettings({ pageSize: 999999 } as Partial<EditorSettings>);
|
||||
expect(patch.pageSize).toBe(normalizeTableOpenPageSizeDraft(999999));
|
||||
});
|
||||
|
||||
it("is the base for the settings import path in the dialog", () => {
|
||||
// The import must patch only imported keys into the edit refs; rebuilding
|
||||
// the whole draft would drop unsaved state the file does not cover (e.g. a
|
||||
// half-filled table-column template row, which serialization drops).
|
||||
expect(settingsDialogSource).toContain("const patch = editorSettingsDraftPatchFromSettings(imported);");
|
||||
expect(settingsDialogSource).toContain("applyEditorSettingsKeysToRefs(patch as EditorSettingsDraft, Object.keys(patch) as EditorSettingsDraftKey[]);");
|
||||
expect(settingsDialogSource).not.toContain("const merged = editorSettingsDraftFromSettings({");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_EDITOR_SETTINGS, type EditorSettings } from "@/stores/settingsStore";
|
||||
import { EDITOR_SETTINGS_DRAFT_KEYS } from "../editorSettingsDraft";
|
||||
import { buildSettingsTransferFilename, collectTransferCategories, parseSettingsTransferFile, serializeSettingsTransfer, SETTINGS_TRANSFER_FORMAT_VERSION, transferCategoryForKey } from "../settingsTransfer";
|
||||
|
||||
function fileWith(editor: Record<string, unknown>, overrides: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION,
|
||||
app: { name: "dbx", version: "1.2.3" },
|
||||
exportedAt: "2026-09-06T00:00:00.000Z",
|
||||
settings: { editor },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("settingsTransfer", () => {
|
||||
it("builds a dated transfer filename", () => {
|
||||
expect(buildSettingsTransferFilename(new Date(2026, 8, 6))).toBe("dbx-settings-2026-09-06.json");
|
||||
});
|
||||
|
||||
it("round-trips the default settings through serialize and parse", () => {
|
||||
const text = serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS, { appVersion: "1.2.3" });
|
||||
const result = parseSettingsTransferFile(text);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.appVersion).toBe("1.2.3");
|
||||
expect(Object.keys(result.value.editorSettings).sort()).toEqual([...EDITOR_SETTINGS_DRAFT_KEYS].sort());
|
||||
expect(result.value.editorSettings.pageSize).toBe(DEFAULT_EDITOR_SETTINGS.pageSize);
|
||||
expect(result.value.editorSettings.snippets).toEqual(DEFAULT_EDITOR_SETTINGS.snippets);
|
||||
expect(result.value.categories).toContain("snippets");
|
||||
expect(result.value.categories).toContain("shortcuts");
|
||||
});
|
||||
|
||||
it("keeps connection timeout ownership out of the payload", () => {
|
||||
const text = serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS);
|
||||
expect(text).not.toContain("connectTimeoutInheritConnectionIds");
|
||||
expect(text).not.toContain("queryTimeoutInheritConnectionIds");
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
const result = parseSettingsTransferFile("{ not json");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-json");
|
||||
});
|
||||
|
||||
it("rejects unsupported format versions", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({}, { formatVersion: 99 }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("unsupported-version");
|
||||
expect(result.error.detail).toBe("99");
|
||||
});
|
||||
|
||||
it("rejects wrong-typed fields", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ fontSize: "big" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("fontSize");
|
||||
});
|
||||
|
||||
it("rejects out-of-domain enum values", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ updateDownloadSource: "evil" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("updateDownloadSource");
|
||||
});
|
||||
|
||||
it("rejects out-of-range numbers that the normalizer clamps", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ sidebarIndent: 99999 }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("sidebarIndent");
|
||||
});
|
||||
|
||||
it("rejects pass-through numeric fields outside the editor font range", () => {
|
||||
const zero = parseSettingsTransferFile(fileWith({ fontSize: 0 }));
|
||||
expect(zero.ok).toBe(false);
|
||||
if (zero.ok) return;
|
||||
expect(zero.error.detail).toContain("fontSize");
|
||||
|
||||
const huge = parseSettingsTransferFile(fileWith({ fontSize: 100 }));
|
||||
expect(huge.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts in-range fractional editor font sizes", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ fontSize: 13.5 }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.fontSize).toBe(13.5);
|
||||
});
|
||||
|
||||
it("rejects pass-through layout enums", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ appLayout: "invalid" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("appLayout");
|
||||
});
|
||||
|
||||
it("rejects pass-through boolean flags with non-boolean values", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ wordWrap: "yes" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.detail).toContain("wordWrap");
|
||||
});
|
||||
|
||||
it("rejects toolbar items whose known keys are not booleans", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ toolbarItems: { dataTransfer: "yes" } }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.detail).toContain("toolbarItems");
|
||||
});
|
||||
|
||||
it("rejects toolbar flags that the normalizer would coerce", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ toolbarItems: { exclusiveRightSidebarPanels: "no" } }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.detail).toContain("toolbarItems");
|
||||
});
|
||||
|
||||
it("rejects empty active custom theme ids", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ activeCustomThemeId: " " }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.detail).toContain("activeCustomThemeId");
|
||||
});
|
||||
|
||||
it("imports only the keys present in the file", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ theme: DEFAULT_EDITOR_SETTINGS.theme }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(Object.keys(result.value.editorSettings)).toEqual(["theme"]);
|
||||
expect(result.value.categories).toEqual(["appearance"]);
|
||||
});
|
||||
|
||||
it("ignores unknown fields instead of importing them", () => {
|
||||
const result = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
theme: DEFAULT_EDITOR_SETTINGS.theme,
|
||||
notARealSetting: "injected",
|
||||
connectTimeoutInheritConnectionIds: ["conn-1"],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings).not.toHaveProperty("notARealSetting");
|
||||
expect(result.value.editorSettings).not.toHaveProperty("connectTimeoutInheritConnectionIds");
|
||||
});
|
||||
|
||||
it("sanitizes object fields through the store normalizer", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ shortcuts: {} }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.shortcuts).toEqual(DEFAULT_EDITOR_SETTINGS.shortcuts);
|
||||
});
|
||||
|
||||
it("rejects files without importable settings", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ notARealSetting: true }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("empty-settings");
|
||||
});
|
||||
|
||||
it("rejects oversized files", () => {
|
||||
const result = parseSettingsTransferFile("x".repeat(5 * 1024 * 1024 + 1));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("too-large");
|
||||
});
|
||||
|
||||
it("maps every whitelisted key to a display category", () => {
|
||||
for (const key of EDITOR_SETTINGS_DRAFT_KEYS) {
|
||||
expect(transferCategoryForKey(key), key).toBeDefined();
|
||||
}
|
||||
expect(transferCategoryForKey("notARealSetting")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects categories in display order without duplicates", () => {
|
||||
expect(collectTransferCategories(["snippets", "theme", "shortcuts", "fontFamily"])).toEqual(["appearance", "shortcuts", "snippets"]);
|
||||
});
|
||||
|
||||
it("does not mutate the shared defaults while validating", () => {
|
||||
const before = JSON.stringify(DEFAULT_EDITOR_SETTINGS) as string;
|
||||
parseSettingsTransferFile(fileWith({ shortcuts: { hijacked: true }, customThemes: [{ id: "x" }] }));
|
||||
expect(JSON.stringify(DEFAULT_EDITOR_SETTINGS)).toBe(before);
|
||||
});
|
||||
|
||||
it("accepts a serialized draft of arbitrary settings unchanged", () => {
|
||||
const settings = {
|
||||
...DEFAULT_EDITOR_SETTINGS,
|
||||
theme: DEFAULT_EDITOR_SETTINGS.theme,
|
||||
pageSize: 200,
|
||||
snippets: [{ id: "s1", label: "L", prefix: "p", body: "SELECT 1", enabled: true }],
|
||||
} as EditorSettings;
|
||||
const result = parseSettingsTransferFile(serializeSettingsTransfer(settings));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.pageSize).toBe(200);
|
||||
expect(result.value.editorSettings.snippets).toEqual(settings.snippets);
|
||||
});
|
||||
|
||||
it("rejects custom theme items that lack id or name", () => {
|
||||
const validTheme = { ...DEFAULT_EDITOR_SETTINGS.customThemes[0] };
|
||||
const idless = parseSettingsTransferFile(fileWith({ customThemes: [{ ...validTheme, id: "" }] }));
|
||||
expect(idless.ok).toBe(false);
|
||||
if (idless.ok) return;
|
||||
expect(idless.error.detail).toContain("customThemes");
|
||||
|
||||
const unnamed = parseSettingsTransferFile(fileWith({ customThemes: [{ ...validTheme, name: undefined }] }));
|
||||
expect(unnamed.ok).toBe(false);
|
||||
if (unnamed.ok) return;
|
||||
expect(unnamed.error.detail).toContain("customThemes");
|
||||
});
|
||||
|
||||
it("rejects custom theme items with wrong-typed colors", () => {
|
||||
const validTheme = DEFAULT_EDITOR_SETTINGS.customThemes[0];
|
||||
const result = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
customThemes: [{ ...validTheme, id: "t1", name: "T1", colors: { ...validTheme.colors, keyword: 5 } }],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("customThemes");
|
||||
});
|
||||
|
||||
it("rejects custom theme items with wrong-typed optional background and foreground colors", () => {
|
||||
const validTheme = DEFAULT_EDITOR_SETTINGS.customThemes[0];
|
||||
const background = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
customThemes: [{ ...validTheme, id: "t1", name: "T1", colors: { ...validTheme.colors, background: {} } }],
|
||||
}),
|
||||
);
|
||||
expect(background.ok).toBe(false);
|
||||
if (background.ok) return;
|
||||
expect(background.error.detail).toContain("customThemes");
|
||||
|
||||
const foreground = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
customThemes: [{ ...validTheme, id: "t1", name: "T1", colors: { ...validTheme.colors, foreground: 5 } }],
|
||||
}),
|
||||
);
|
||||
expect(foreground.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts custom theme items with optional background and foreground colors", () => {
|
||||
const base = DEFAULT_EDITOR_SETTINGS.customThemes[0];
|
||||
const validTheme = { ...base, id: "t1", name: "T1", colors: { ...base.colors, background: "#282c34", foreground: "#abb2bf" } };
|
||||
const result = parseSettingsTransferFile(fileWith({ customThemes: [validTheme] }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.customThemes).toEqual([validTheme]);
|
||||
});
|
||||
|
||||
it("rejects null custom theme items instead of crashing", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ customThemes: [null] }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("customThemes");
|
||||
});
|
||||
|
||||
it("accepts complete custom themes and data grid color schemes", () => {
|
||||
const validTheme = { ...DEFAULT_EDITOR_SETTINGS.customThemes[0], id: "t1", name: "T1" };
|
||||
const scheme = {
|
||||
id: "s1",
|
||||
name: "S1",
|
||||
colors: { integer: "#1d4ed8", numeric: "#0e7490", string: "#166534", boolean: "#c2410c", temporal: "#7e22ce", structured: "#be185d", identifier: "#92400e", binary: "#b91c1c", spatial: "#047857" },
|
||||
};
|
||||
const result = parseSettingsTransferFile(fileWith({ customThemes: [validTheme], dataGridTypeColorSchemes: [scheme] }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.customThemes).toEqual([validTheme]);
|
||||
expect(result.value.editorSettings.dataGridTypeColorSchemes).toEqual([scheme]);
|
||||
});
|
||||
|
||||
it("rejects data grid color schemes claiming the reserved auto id", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ dataGridTypeColorSchemes: [{ id: "auto", name: "S" }] }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("dataGridTypeColorSchemes");
|
||||
});
|
||||
|
||||
it("rejects malformed snippet items instead of substituting defaults", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ snippets: [{}] }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("snippets");
|
||||
});
|
||||
|
||||
it("rejects malformed sql shortcut items", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ sqlShortcuts: [{ id: "s1" }] }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("sqlShortcuts");
|
||||
});
|
||||
|
||||
it("rejects non-string entries in string array settings", () => {
|
||||
const redis = parseSettingsTransferFile(fileWith({ redisKeyTemplates: [5] }));
|
||||
expect(redis.ok).toBe(false);
|
||||
if (redis.ok) return;
|
||||
expect(redis.error.detail).toContain("redisKeyTemplates");
|
||||
|
||||
const prefixes = parseSettingsTransferFile(fileWith({ sidebarHiddenTablePrefixes: [null] }));
|
||||
expect(prefixes.ok).toBe(false);
|
||||
if (prefixes.ok) return;
|
||||
expect(prefixes.error.detail).toContain("sidebarHiddenTablePrefixes");
|
||||
});
|
||||
|
||||
it("rejects formatter settings missing a required option", () => {
|
||||
const partialFormatter: Record<string, unknown> = { ...DEFAULT_EDITOR_SETTINGS.sqlFormatter };
|
||||
delete partialFormatter.keywordCase;
|
||||
const result = parseSettingsTransferFile(fileWith({ sqlFormatter: partialFormatter }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("sqlFormatter");
|
||||
});
|
||||
|
||||
it("rejects malformed sql variable syntax overrides", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ sqlVariableSyntaxOverrides: { mysql: { positional: "yes" } } }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("sqlVariableSyntaxOverrides");
|
||||
});
|
||||
|
||||
it("rejects shortcut overrides whose known keys are not strings", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ shortcuts: { executeSql: 5 } }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("shortcuts");
|
||||
});
|
||||
|
||||
it("maps csvQuoteMode into the data category", () => {
|
||||
expect(transferCategoryForKey("csvQuoteMode")).toBe("data");
|
||||
expect(collectTransferCategories(["csvQuoteMode", "pageSize"])).toEqual(["data"]);
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,20 @@ export function editorSettingsDraftFromSettings(settings: EditorSettings): Edito
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft-shaped, per-key-normalized values for exactly the keys present in
|
||||
* `settings`. Used for partial updates (e.g. settings import) where keys the
|
||||
* input does not contain must leave the target state untouched.
|
||||
*/
|
||||
export function editorSettingsDraftPatchFromSettings(settings: Partial<EditorSettings>): Partial<EditorSettingsDraft> {
|
||||
const patch: Partial<EditorSettingsDraft> = {};
|
||||
for (const key of EDITOR_SETTINGS_DRAFT_KEYS) {
|
||||
if (!(key in settings)) continue;
|
||||
(patch as Record<string, unknown>)[key] = cloneDraftValue(normalizedDraftValue(key, settings[key])) as never;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function editorSettingsPatchFromDraft(draft: EditorSettingsDraft, base: EditorSettingsDraft): Partial<EditorSettings> {
|
||||
const patch: Partial<EditorSettings> = {};
|
||||
for (const key of EDITOR_SETTINGS_DRAFT_KEYS) {
|
||||
|
||||
@@ -246,6 +246,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
|
||||
{ id: "security", category: "security", titleKey: "settings.securityTab", targetId: "security", visible: webOnly },
|
||||
{ id: "security-password", category: "security", titleKey: "auth.changePassword", targetId: "security", visible: webOnly },
|
||||
{ id: "about-support", category: "about", titleKey: "settings.supportInfoTitle", descriptionKey: "settings.supportInfoDescription", targetId: "about" },
|
||||
{ id: "about-transfer", category: "about", titleKey: "settings.settingsTransferTitle", descriptionKey: "settings.settingsTransferDescription", targetId: "about" },
|
||||
{ id: "about-update", category: "about", titleKey: "settings.updateDownloadSource", descriptionKey: "settings.updateDownloadSourceDescription", targetId: "about" },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, type EditorSettingsDraftKey } from "./editorSettingsDraft";
|
||||
import { DEFAULT_CUSTOM_THEME_COLORS, DEFAULT_CUSTOM_THEME_DDL_COLORS, DEFAULT_EDITOR_SETTINGS, DEFAULT_TOOLBAR_ITEMS, normalizeEditorSettings, type EditorSettings } from "@/stores/settingsStore";
|
||||
import { EDITOR_MAX_FONT_SIZE, EDITOR_MIN_FONT_SIZE } from "@/lib/editor/editorZoom";
|
||||
import { DATA_GRID_TYPE_COLOR_KEYS, DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID } from "@/lib/dataGrid/dataGridTypeColorScheme";
|
||||
import { SHORTCUT_DEFINITIONS } from "@/lib/editor/shortcutRegistry";
|
||||
import { isCompleteSqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { SQL_VARIABLE_SYNTAX_KEYS } from "@/lib/sql/sqlVariableSyntax";
|
||||
|
||||
/**
|
||||
* Local backup / restore of application settings ("配置导入与导出").
|
||||
*
|
||||
* The transfer file is a versioned JSON document with a strict field
|
||||
* whitelist: only dialog-managed editor settings are serialized. Connection
|
||||
* data, saved SQL libraries, AI configuration and device-specific paths are
|
||||
* never part of the payload, and unknown fields in an imported file are
|
||||
* ignored instead of being written into the draft.
|
||||
*/
|
||||
|
||||
export const SETTINGS_TRANSFER_FORMAT_VERSION = 1;
|
||||
|
||||
/** Upper bound for an import file; real settings files are a few dozen KB. */
|
||||
export const MAX_SETTINGS_TRANSFER_FILE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export type SettingsTransferCategoryId = "appearance" | "editor" | "formatter" | "navigation" | "data" | "shortcuts" | "snippets" | "other";
|
||||
|
||||
export type SettingsTransferParseErrorCode = "too-large" | "invalid-json" | "unsupported-version" | "invalid-structure" | "empty-settings" | "invalid-fields";
|
||||
|
||||
export interface SettingsTransferParseError {
|
||||
code: SettingsTransferParseErrorCode;
|
||||
/** Offending format version, or the first invalid field names. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ParsedSettingsTransfer {
|
||||
formatVersion: number;
|
||||
appVersion?: string;
|
||||
exportedAt?: string;
|
||||
/** Validated, normalized values for exactly the keys present in the file. */
|
||||
editorSettings: Partial<EditorSettings>;
|
||||
categories: SettingsTransferCategoryId[];
|
||||
}
|
||||
|
||||
export interface SettingsTransferExportMeta {
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export function buildSettingsTransferFilename(date = new Date()): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `dbx-settings-${year}-${month}-${day}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the saved editor settings into the transfer format. The draft
|
||||
* helper provides the whitelist, deep cloning and per-key normalization, so
|
||||
* the payload never contains fields outside `EDITOR_SETTINGS_DRAFT_KEYS`.
|
||||
*/
|
||||
export function serializeSettingsTransfer(settings: EditorSettings, meta: SettingsTransferExportMeta = {}): string {
|
||||
const payload = {
|
||||
formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION,
|
||||
app: { name: "dbx", ...(meta.appVersion ? { version: meta.appVersion } : {}) },
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings: {
|
||||
editor: editorSettingsDraftFromSettings(settings),
|
||||
},
|
||||
};
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
const SETTINGS_TRANSFER_CATEGORY_ORDER: readonly SettingsTransferCategoryId[] = ["appearance", "editor", "formatter", "navigation", "data", "shortcuts", "snippets", "other"];
|
||||
|
||||
const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readonly EditorSettingsDraftKey[]> = {
|
||||
appearance: ["fontFamily", "fontSize", "tableFontFamily", "uiFontFamily", "uiScale", "theme", "customThemes", "activeCustomThemeId", "toolbarItems", "updateNotificationsEnabled"],
|
||||
editor: [
|
||||
"executeMode",
|
||||
"defaultTransactionMode",
|
||||
"executeAllOnBlankLine",
|
||||
"showExecutionTargetPicker",
|
||||
"showStatementRunButtons",
|
||||
"showLineNumbers",
|
||||
"showCurrentStatementFrame",
|
||||
"showInsertValueHints",
|
||||
"autoAliasTables",
|
||||
"insertSpaceAfterCompletion",
|
||||
"sortCompletionColumnsAlphabetically",
|
||||
"selectFirstCompletionOnOpen",
|
||||
"completionTriggerMode",
|
||||
"wordWrap",
|
||||
"vimModeEnabled",
|
||||
"autoCloseBrackets",
|
||||
"sqlSemanticDiagnosticsMode",
|
||||
"confirmDangerousSqlExecution",
|
||||
"continueOnErrorOnBatch",
|
||||
"confirmUnsavedSqlClose",
|
||||
"appCloseUnsavedTabsMode",
|
||||
"savedSqlOpenTargetMode",
|
||||
"prefillNewQueryWithSelect",
|
||||
"generateSqlIncludeDatabaseName",
|
||||
"generateSqlQuoteIdentifiers",
|
||||
"formatSqlOnSqlFileSave",
|
||||
"showTableDdlHoverPreview",
|
||||
"sqlVariableSubstitutionEnabled",
|
||||
"sqlVariableSyntaxOverrides",
|
||||
],
|
||||
formatter: ["sqlFormatter"],
|
||||
navigation: [
|
||||
"appLayout",
|
||||
"tabLayout",
|
||||
"tabPlacement",
|
||||
"tabGroupMode",
|
||||
"tabSortMode",
|
||||
"sidebarActivation",
|
||||
"sidebarObjectDisplay",
|
||||
"routineSourceOpenMode",
|
||||
"sidebarTableSearchEnabled",
|
||||
"autoSelectActiveSidebarNode",
|
||||
"sidebarBrowseObjectsOnDatabaseActivation",
|
||||
"openTabsRestoreMode",
|
||||
"disconnectTabHandlingMode",
|
||||
"dataTabReuseMode",
|
||||
"openDataTabsNextToActive",
|
||||
"clickTableNavigationTarget",
|
||||
"sidebarObjectInfoMode",
|
||||
"sidebarAllowHorizontalScroll",
|
||||
"sidebarShowTooltips",
|
||||
"sidebarIndent",
|
||||
"sidebarFontSize",
|
||||
"sidebarHiddenTablePrefixes",
|
||||
"sidebarCopyTableNameSeparator",
|
||||
"sidebarCopyTableNameIncludeSchema",
|
||||
],
|
||||
data: [
|
||||
"showColumnCommentsInHeader",
|
||||
"showColumnTypesInHeader",
|
||||
"dataGridShowTransposeFieldMetadata",
|
||||
"colorizeDataGridCellTypes",
|
||||
"dataGridTypeColorSchemes",
|
||||
"activeDataGridTypeColorSchemeId",
|
||||
"showIndexIndicatorsInHeader",
|
||||
"compactColumnHeaderActions",
|
||||
"dataGridQuickEntry",
|
||||
"dataGridFilterEditorView",
|
||||
"dataGridAutoHideFilterBuilder",
|
||||
"dataGridTextFilterPanelHeight",
|
||||
"multiStatementDefaultView",
|
||||
"dataGridAutoTransposeSingleRow",
|
||||
"dataGridCellDetailButtonVisible",
|
||||
"dataGridCrosshairHighlight",
|
||||
"flatteningMultiLineText",
|
||||
"pageSize",
|
||||
"tableOpenPageSize",
|
||||
"queryResultMaxRowsEnabled",
|
||||
"queryResultMaxRows",
|
||||
"infiniteScroll",
|
||||
"regexMaxMatchCount",
|
||||
"autoCalculateTotalRows",
|
||||
"tableColumnTemplateFields",
|
||||
"redisKeyTemplates",
|
||||
"exportBatchSize",
|
||||
"csvQuoteMode",
|
||||
"exportRowLimitEnabled",
|
||||
"exportRowLimit",
|
||||
"queryExportKeysetOptimizationEnabled",
|
||||
"globalDateTimeDisplayFormat",
|
||||
"globalDateTimeExportFormat",
|
||||
"globalDateTimeImportFormat",
|
||||
],
|
||||
shortcuts: ["shortcuts", "sqlShortcuts"],
|
||||
snippets: ["snippets"],
|
||||
other: ["updateDownloadSource"],
|
||||
};
|
||||
|
||||
const KEY_TO_CATEGORY = new Map<string, SettingsTransferCategoryId>();
|
||||
for (const [category, keys] of Object.entries(SETTINGS_TRANSFER_CATEGORY_KEYS) as [SettingsTransferCategoryId, readonly EditorSettingsDraftKey[]][]) {
|
||||
for (const key of keys) KEY_TO_CATEGORY.set(key, category);
|
||||
}
|
||||
|
||||
export function transferCategoryForKey(key: string): SettingsTransferCategoryId | undefined {
|
||||
return KEY_TO_CATEGORY.get(key);
|
||||
}
|
||||
|
||||
export function collectTransferCategories(keys: readonly string[]): SettingsTransferCategoryId[] {
|
||||
const present = new Set<SettingsTransferCategoryId>();
|
||||
for (const key of keys) {
|
||||
const category = KEY_TO_CATEGORY.get(key);
|
||||
if (category) present.add(category);
|
||||
}
|
||||
return sortTransferCategories(present);
|
||||
}
|
||||
|
||||
/** Orders category ids by their fixed display order. */
|
||||
export function sortTransferCategories(categories: Iterable<SettingsTransferCategoryId>): SettingsTransferCategoryId[] {
|
||||
const present = new Set(categories);
|
||||
return SETTINGS_TRANSFER_CATEGORY_ORDER.filter((category) => present.has(category));
|
||||
}
|
||||
|
||||
/** i18n keys used to render the affected categories in the import dialog. */
|
||||
export const SETTINGS_TRANSFER_CATEGORY_LABEL_KEYS: Record<SettingsTransferCategoryId, string> = {
|
||||
appearance: "settings.appearanceTab",
|
||||
editor: "settings.editorTab",
|
||||
formatter: "settings.sqlFormatterTab",
|
||||
navigation: "settings.navigationTab",
|
||||
data: "settings.dataTab",
|
||||
shortcuts: "settings.shortcutsTab",
|
||||
snippets: "settings.snippetsTab",
|
||||
other: "settings.settingsTransferCategoryOther",
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* UTF-8 needs 1–3 bytes per UTF-16 code unit, so the character count alone
|
||||
* settles every text outside the ambiguous expansion band. The precise byte
|
||||
* count — which allocates an encoded copy of the whole text — is only built
|
||||
* for inputs whose size could still tip over the limit after expansion.
|
||||
*/
|
||||
function exceedsSettingsTransferSizeLimit(text: string): boolean {
|
||||
if (text.length > MAX_SETTINGS_TRANSFER_FILE_BYTES) return true;
|
||||
if (text.length * 3 <= MAX_SETTINGS_TRANSFER_FILE_BYTES) return false;
|
||||
return new TextEncoder().encode(text).byteLength > MAX_SETTINGS_TRANSFER_FILE_BYTES;
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function jsonKindOf(value: unknown): string {
|
||||
return Array.isArray(value) ? "array" : typeof value;
|
||||
}
|
||||
|
||||
// Expected JSON kind per whitelisted key, derived from the defaults. The store
|
||||
// normalizer is defensive but a few keys pass values through unchanged, so the
|
||||
// kind check is what rejects wrong-typed fields (e.g. a string fontSize).
|
||||
const EXPECTED_JSON_KINDS = new Map<string, string>(EDITOR_SETTINGS_DRAFT_KEYS.map((key) => [key, jsonKindOf((DEFAULT_EDITOR_SETTINGS as unknown as Record<string, unknown>)[key])]));
|
||||
|
||||
/**
|
||||
* Keys whose store normalizer keeps any non-nullish value as-is (only a
|
||||
* nullish fallback). For these, the round-trip equality check cannot catch a
|
||||
* bad value: `fontSize: 0` or `appLayout: "invalid"` would survive untouched
|
||||
* and later be persisted. Each entry restates the domain the settings UI and
|
||||
* the clamping helpers enforce, so importing an out-of-range number, a broken
|
||||
* enum or a non-boolean flag refuses the whole file instead.
|
||||
*/
|
||||
const PASS_THROUGH_BOOLEAN_KEYS = [
|
||||
"wordWrap",
|
||||
"showExecutionTargetPicker",
|
||||
"autoAliasTables",
|
||||
"confirmDangerousSqlExecution",
|
||||
"confirmUnsavedSqlClose",
|
||||
"showColumnCommentsInHeader",
|
||||
"showColumnTypesInHeader",
|
||||
"colorizeDataGridCellTypes",
|
||||
"showIndexIndicatorsInHeader",
|
||||
"compactColumnHeaderActions",
|
||||
"dataGridQuickEntry",
|
||||
"flatteningMultiLineText",
|
||||
"infiniteScroll",
|
||||
"autoCalculateTotalRows",
|
||||
"autoSelectActiveSidebarNode",
|
||||
"sidebarAllowHorizontalScroll",
|
||||
"sidebarShowTooltips",
|
||||
"updateNotificationsEnabled",
|
||||
] as const satisfies readonly EditorSettingsDraftKey[];
|
||||
|
||||
const PASS_THROUGH_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (value: unknown) => boolean>> = {
|
||||
// The editor font slider and the Ctrl+wheel zoom both clamp to this range.
|
||||
fontSize: (value) => typeof value === "number" && Number.isFinite(value) && value >= EDITOR_MIN_FONT_SIZE && value <= EDITOR_MAX_FONT_SIZE,
|
||||
appLayout: (value) => value === "separated" || value === "classic",
|
||||
activeCustomThemeId: (value) => typeof value === "string" && value.trim().length > 0,
|
||||
// normalizeToolbarItems keeps unknown/typed values for every known key, so
|
||||
// each one must already be the boolean the UI writes, and no extra key may
|
||||
// appear.
|
||||
toolbarItems: (value) => isPlainObject(value) && Object.keys(DEFAULT_TOOLBAR_ITEMS).every((key) => typeof value[key] === "boolean") && Object.keys(value).every((key) => key in DEFAULT_TOOLBAR_ITEMS),
|
||||
...Object.fromEntries(PASS_THROUGH_BOOLEAN_KEYS.map((key) => [key, (value: unknown) => typeof value === "boolean"])),
|
||||
};
|
||||
|
||||
function isNonEmptyTrimmedString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isArrayOfShape(value: unknown, isItem: (item: unknown) => boolean): boolean {
|
||||
return Array.isArray(value) && value.every(isItem);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): boolean {
|
||||
return isStringArray(value) && (value as string[]).every((item) => item.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Required color keys of a custom editor theme, keyed by expected JSON kind. */
|
||||
function valueKindShape(defaults: Record<string, unknown>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(defaults).map(([key, value]) => [key, typeof value]));
|
||||
}
|
||||
|
||||
const CUSTOM_THEME_COLORS_SHAPE = valueKindShape(DEFAULT_CUSTOM_THEME_COLORS as unknown as Record<string, unknown>);
|
||||
const CUSTOM_THEME_DDL_COLORS_SHAPE = valueKindShape(DEFAULT_CUSTOM_THEME_DDL_COLORS as unknown as Record<string, unknown>);
|
||||
|
||||
/**
|
||||
* Optional color keys of a custom editor theme. They are absent from
|
||||
* DEFAULT_CUSTOM_THEME_COLORS, so the defaults-derived shape above cannot
|
||||
* cover them and a wrong-typed value (e.g. `background: {}`) would otherwise
|
||||
* pass validation and reach the persisted settings.
|
||||
*/
|
||||
const CUSTOM_THEME_OPTIONAL_COLOR_KEYS = ["background", "foreground"] as const;
|
||||
|
||||
function hasValidOptionalColorKinds(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return CUSTOM_THEME_OPTIONAL_COLOR_KEYS.every((key) => value[key] === undefined || typeof value[key] === "string");
|
||||
}
|
||||
|
||||
function hasRequiredValueKinds(value: unknown, shape: Record<string, string>): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.entries(shape).every(([key, kind]) => typeof value[key] === kind);
|
||||
}
|
||||
|
||||
function isCustomThemeItem(value: unknown): boolean {
|
||||
return isPlainObject(value) && isNonEmptyTrimmedString(value.id) && isNonEmptyTrimmedString(value.name) && hasRequiredValueKinds(value.colors, CUSTOM_THEME_COLORS_SHAPE) && hasValidOptionalColorKinds(value.colors) && hasRequiredValueKinds(value.ddlColors, CUSTOM_THEME_DDL_COLORS_SHAPE);
|
||||
}
|
||||
|
||||
// The store normalizer only writes #rrggbb values, matching normalizeDataGridTypeColors.
|
||||
const DATA_GRID_TYPE_HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
function isDataGridTypeColorSchemeItem(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
// The auto id is the "follow the built-in palette" sentinel, never a scheme.
|
||||
if (!isNonEmptyTrimmedString(value.id) || value.id === DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID) return false;
|
||||
if (!isNonEmptyTrimmedString(value.name)) return false;
|
||||
const colors = value.colors;
|
||||
if (!isPlainObject(colors)) return false;
|
||||
return DATA_GRID_TYPE_COLOR_KEYS.every((key) => {
|
||||
const color = colors[key];
|
||||
return typeof color === "string" && DATA_GRID_TYPE_HEX_COLOR_RE.test(color);
|
||||
});
|
||||
}
|
||||
|
||||
function isSqlSnippetItem(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
if (!isNonEmptyTrimmedString(value.id) || !isNonEmptyTrimmedString(value.label) || !isNonEmptyTrimmedString(value.prefix) || typeof value.body !== "string") return false;
|
||||
return value.enabled === undefined || typeof value.enabled === "boolean";
|
||||
}
|
||||
|
||||
function isSqlShortcutActionItem(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
if (!isNonEmptyTrimmedString(value.id) || !isNonEmptyTrimmedString(value.label) || typeof value.shortcut !== "string" || typeof value.sql !== "string") return false;
|
||||
return value.enabled === undefined || typeof value.enabled === "boolean";
|
||||
}
|
||||
|
||||
const SHORTCUT_ACTION_IDS = new Set<string>(SHORTCUT_DEFINITIONS.map((definition) => definition.id));
|
||||
|
||||
/** Normalized shape: exactly the known action ids, each bound to a string. */
|
||||
function isShortcutSettingsShape(value: unknown): boolean {
|
||||
return isPlainObject(value) && Object.entries(value).every(([key, entry]) => SHORTCUT_ACTION_IDS.has(key) && typeof entry === "string");
|
||||
}
|
||||
|
||||
/** Raw shape: overrides for known actions must be strings; unknown keys stay ignored for forward compatibility. */
|
||||
function isRawShortcutSettingsShape(value: unknown): boolean {
|
||||
return isPlainObject(value) && Object.entries(value).every(([key, entry]) => !SHORTCUT_ACTION_IDS.has(key) || typeof entry === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw shape: every known toolbar key must already be a boolean.
|
||||
* normalizeToolbarItems coerces `exclusiveRightSidebarPanels` with `!== false`
|
||||
* (and fills the rest with nullish defaults), so a non-boolean like "no" would
|
||||
* otherwise be silently turned into `true` and pass the normalized round-trip
|
||||
* check. Unknown keys stay ignored for forward compatibility.
|
||||
*/
|
||||
function isRawToolbarItemsShape(value: unknown): boolean {
|
||||
return isPlainObject(value) && Object.entries(value).every(([key, entry]) => !(key in DEFAULT_TOOLBAR_ITEMS) || typeof entry === "boolean");
|
||||
}
|
||||
|
||||
const SQL_VARIABLE_SYNTAX_KEY_SET = new Set<string>(SQL_VARIABLE_SYNTAX_KEYS);
|
||||
|
||||
function isSqlVariableSyntaxOverridesShape(value: unknown, isAllowedToggle: (toggle: unknown) => boolean): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.values(value).every((entry) => isPlainObject(entry) && Object.entries(entry).every(([key, toggle]) => SQL_VARIABLE_SYNTAX_KEY_SET.has(key) && isAllowedToggle(toggle)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Full nested-schema checks for the structured (array/object) fields, run on
|
||||
* the normalized value. The store normalizer only sanitizes some of them —
|
||||
* notably `normalizeEditorSettings` keeps `customThemes` items that lack
|
||||
* `id` or `name` — so without these checks a file like
|
||||
* `{"customThemes": [{}]}` would import cleanly and later corrupt the
|
||||
* persisted settings.
|
||||
*/
|
||||
const NESTED_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (value: unknown) => boolean>> = {
|
||||
customThemes: (value) => isArrayOfShape(value, isCustomThemeItem),
|
||||
dataGridTypeColorSchemes: (value) => isArrayOfShape(value, isDataGridTypeColorSchemeItem),
|
||||
tableColumnTemplateFields: isNonEmptyStringArray,
|
||||
shortcuts: isShortcutSettingsShape,
|
||||
sqlFormatter: isCompleteSqlFormatterSettings,
|
||||
sidebarHiddenTablePrefixes: isNonEmptyStringArray,
|
||||
redisKeyTemplates: isNonEmptyStringArray,
|
||||
snippets: (value) => isArrayOfShape(value, isSqlSnippetItem),
|
||||
sqlShortcuts: (value) => isArrayOfShape(value, isSqlShortcutActionItem),
|
||||
sqlVariableSyntaxOverrides: (value) => isSqlVariableSyntaxOverridesShape(value, (toggle) => toggle === false),
|
||||
};
|
||||
|
||||
/**
|
||||
* The same nested schemas applied to the raw file values before
|
||||
* normalization. This rejects payloads the normalizer would silently repair
|
||||
* by dropping entries or substituting defaults — importing those would
|
||||
* overwrite the user's data (custom schemes, snippets, formatter options, …)
|
||||
* with sanitized defaults instead of surfacing the malformed file. Running
|
||||
* them before the normalizer also keeps `normalizeEditorSettings` from
|
||||
* throwing on malformed containers it does not sanitize (e.g. a null item
|
||||
* inside `customThemes`).
|
||||
*/
|
||||
const RAW_STRUCTURED_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (value: unknown) => boolean>> = {
|
||||
customThemes: (value) => isArrayOfShape(value, isCustomThemeItem),
|
||||
dataGridTypeColorSchemes: (value) => isArrayOfShape(value, isDataGridTypeColorSchemeItem),
|
||||
tableColumnTemplateFields: isStringArray,
|
||||
shortcuts: isRawShortcutSettingsShape,
|
||||
toolbarItems: isRawToolbarItemsShape,
|
||||
sqlFormatter: isCompleteSqlFormatterSettings,
|
||||
sidebarHiddenTablePrefixes: isStringArray,
|
||||
redisKeyTemplates: isStringArray,
|
||||
snippets: (value) => isArrayOfShape(value, isSqlSnippetItem),
|
||||
sqlShortcuts: (value) => isArrayOfShape(value, isSqlShortcutActionItem),
|
||||
sqlVariableSyntaxOverrides: (value) => isSqlVariableSyntaxOverridesShape(value, (toggle) => typeof toggle === "boolean"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses and validates an imported settings file. Validation is all-or-
|
||||
* nothing: the caller only receives values once every whitelisted field in
|
||||
* the file passed the checks, so a rejected file can never partially modify
|
||||
* the settings draft.
|
||||
*
|
||||
* Raw JSON-kind and raw structured checks (see
|
||||
* RAW_STRUCTURED_FIELD_VALIDATORS) run first, before the normalizer, so a
|
||||
* malformed container is reported as `invalid-fields` instead of throwing
|
||||
* inside a store normalizer. Scalar values (boolean/number/string) must
|
||||
* survive the store's own normalizer unchanged — an out-of-domain enum or a
|
||||
* clamped number comes back different and rejects the import. Pass-through
|
||||
* fields (see PASS_THROUGH_FIELD_VALIDATORS) get an explicit domain check
|
||||
* instead, because the normalizer keeps them as-is. Objects and arrays are
|
||||
* sanitized through the same normalizer and must additionally satisfy a full
|
||||
* nested schema (see NESTED_FIELD_VALIDATORS): malformed nested payloads are
|
||||
* rejected instead of being silently repaired or persisted.
|
||||
*/
|
||||
export function parseSettingsTransferFile(text: string): { ok: true; value: ParsedSettingsTransfer } | { ok: false; error: SettingsTransferParseError } {
|
||||
if (exceedsSettingsTransferSizeLimit(text)) {
|
||||
return { ok: false, error: { code: "too-large" } };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, error: { code: "invalid-json" } };
|
||||
}
|
||||
if (!isPlainObject(parsed)) return { ok: false, error: { code: "invalid-structure" } };
|
||||
|
||||
const formatVersion = parsed.formatVersion;
|
||||
if (typeof formatVersion !== "number" || !Number.isInteger(formatVersion) || formatVersion !== SETTINGS_TRANSFER_FORMAT_VERSION) {
|
||||
return { ok: false, error: { code: "unsupported-version", detail: typeof formatVersion === "number" ? String(formatVersion) : undefined } };
|
||||
}
|
||||
|
||||
const settings = parsed.settings;
|
||||
if (!isPlainObject(settings)) return { ok: false, error: { code: "invalid-structure" } };
|
||||
const editor = settings.editor;
|
||||
if (!isPlainObject(editor)) return { ok: false, error: { code: "empty-settings" } };
|
||||
|
||||
const presentKeys = EDITOR_SETTINGS_DRAFT_KEYS.filter((key) => key in editor);
|
||||
if (presentKeys.length === 0) return { ok: false, error: { code: "empty-settings" } };
|
||||
|
||||
// Validate the raw file values BEFORE the normalizer runs. Some store
|
||||
// normalizers assume well-formed containers (e.g. customThemes items must
|
||||
// expose `name`) and throw on malformed input instead of sanitizing it, so
|
||||
// a broken file must be rejected here and surface as `invalid-fields`
|
||||
// rather than crashing the parse.
|
||||
const invalidKeys: string[] = [];
|
||||
for (const key of presentKeys) {
|
||||
const raw = editor[key];
|
||||
if (jsonKindOf(raw) !== EXPECTED_JSON_KINDS.get(key)) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
const rawValidator = RAW_STRUCTURED_FIELD_VALIDATORS[key];
|
||||
if (rawValidator && !rawValidator(raw)) {
|
||||
invalidKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (invalidKeys.length > 0) {
|
||||
return { ok: false, error: { code: "invalid-fields", detail: invalidKeys.slice(0, 5).join(", ") } };
|
||||
}
|
||||
|
||||
// Run the imported values through the same normalizer the store applies on
|
||||
// load. Defaults are cloned first so the shared constant can never be
|
||||
// mutated by a malformed file.
|
||||
const probe = deepClone(DEFAULT_EDITOR_SETTINGS) as EditorSettings;
|
||||
for (const key of presentKeys) {
|
||||
(probe as unknown as Record<string, unknown>)[key] = editor[key];
|
||||
}
|
||||
const normalized = normalizeEditorSettings(probe);
|
||||
|
||||
const imported: Partial<EditorSettings> = {};
|
||||
for (const key of presentKeys) {
|
||||
const raw = editor[key];
|
||||
const value = (normalized as unknown as Record<string, unknown>)[key];
|
||||
const validator = PASS_THROUGH_FIELD_VALIDATORS[key] ?? NESTED_FIELD_VALIDATORS[key];
|
||||
if (validator && !validator(value)) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
if (typeof raw === "boolean" || typeof raw === "number" || typeof raw === "string") {
|
||||
if (typeof raw !== typeof value || value !== raw) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
imported[key as EditorSettingsDraftKey] = deepClone(value) as never;
|
||||
} else if (Array.isArray(raw)) {
|
||||
if (!Array.isArray(value)) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
imported[key as EditorSettingsDraftKey] = deepClone(value) as never;
|
||||
} else if (isPlainObject(raw)) {
|
||||
if (!isPlainObject(value)) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
imported[key as EditorSettingsDraftKey] = deepClone(value) as never;
|
||||
} else {
|
||||
invalidKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (invalidKeys.length > 0) {
|
||||
return { ok: false, error: { code: "invalid-fields", detail: invalidKeys.slice(0, 5).join(", ") } };
|
||||
}
|
||||
|
||||
const app = isPlainObject(parsed.app) ? parsed.app : {};
|
||||
const rawAppVersion = typeof app.version === "string" ? app.version.trim() : "";
|
||||
const rawExportedAt = typeof parsed.exportedAt === "string" ? parsed.exportedAt : "";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION,
|
||||
...(rawAppVersion ? { appVersion: rawAppVersion } : {}),
|
||||
...(rawExportedAt ? { exportedAt: rawExportedAt } : {}),
|
||||
editorSettings: imported,
|
||||
categories: collectTransferCategories(Object.keys(imported)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -208,6 +208,21 @@ export function normalizeSqlFormatterSettings(value: unknown): SqlFormatterSetti
|
||||
return sqlFormatterOptionSettings(optionSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete nested-schema check for an already-shaped settings object: every
|
||||
* formatter option must be present with a valid value, and no unknown option
|
||||
* may appear. Unlike the normalizer this never fills defaults, so callers can
|
||||
* reject partially malformed payloads instead of silently substituting them.
|
||||
*/
|
||||
export function isCompleteSqlFormatterSettings(value: unknown): value is SqlFormatterSettings {
|
||||
if (!isObject(value)) return false;
|
||||
for (const key of SQL_FORMATTER_OPTION_KEYS) {
|
||||
if (!(key in value)) return false;
|
||||
if (!SQL_FORMATTER_OPTION_VALIDATORS[key]((value as Record<string, unknown>)[key])) return false;
|
||||
}
|
||||
return Object.keys(value).every((key) => SQL_FORMATTER_OPTION_KEYS.has(key as keyof SqlFormatterOptionSettings));
|
||||
}
|
||||
|
||||
export function sqlFormatterConfigFile(settings: unknown): SqlFormatterConfigFile {
|
||||
const normalized = normalizeSqlFormatterSettings(settings);
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user