v0.7.8: MySQL/SQLite backup-sync, Excel export, query cancel, settings import/export, Windows title-bar fix, SQLite table editor (#16)
* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1) * [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2) * [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3) * [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1) * [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2) * [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3) * [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4) * [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5) * [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1) * [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2) * [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3) * [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1) * [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2) * [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3) * [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4) * [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5) * [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6) * [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1) * [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2) * fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast * fix(export): Excel export from overflow menu did nothing + add export success/error toasts * fix(export): tree kebab export fetches table data when rows not loaded * docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.7)", () => {
|
||||
describe("tauri bundle config (v0.7.8)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.7", () => {
|
||||
expect(tauriConf.version).toBe("0.7.7");
|
||||
it("version is 0.7.8", () => {
|
||||
expect(tauriConf.version).toBe("0.7.8");
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
getObjectDdl,
|
||||
getObjectDependencies,
|
||||
} from "./commands";
|
||||
import * as cmd from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
import type { QueryHistoryEntry } from "./commands";
|
||||
|
||||
@@ -423,4 +424,12 @@ describe("v0.7.7 command wrappers", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("build_rebuild_script", { connectionId: "c1", schema: "public", table: "users", newColumns: cols });
|
||||
expect(result).toEqual(mockScript);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.8 command wrappers exist", () => {
|
||||
it("exports the backup/cancel/settings wrappers", () => {
|
||||
for (const name of ["cancelQuery","mysqlDump","mysqlRestore","mysqlSync","detectMysqlTools","sqliteDump","sqliteRestore","sqliteSync","exportSettings","importSettings"]) {
|
||||
expect(typeof (cmd as Record<string, unknown>)[name]).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
+43
-1
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
|
||||
@@ -162,6 +162,48 @@ export async function dbSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("db_sync", { options });
|
||||
}
|
||||
|
||||
// ─── v0.7.8: Cancel / MySQL / SQLite / Settings export-import ────
|
||||
|
||||
export async function cancelQuery(connectionId: string): Promise<void> {
|
||||
return invoke<void>("cancel_query", { connectionId });
|
||||
}
|
||||
|
||||
export async function detectMysqlTools(): Promise<MySqlToolStatus> {
|
||||
return invoke<MySqlToolStatus>("detect_mysql_tools");
|
||||
}
|
||||
|
||||
export async function mysqlDump(connectionId: string, options: MySqlBackupOptions): Promise<string> {
|
||||
return invoke<string>("mysql_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function mysqlRestore(connectionId: string, options: MySqlRestoreOptions): Promise<string> {
|
||||
return invoke<string>("mysql_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function mysqlSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("mysql_sync", { options });
|
||||
}
|
||||
|
||||
export async function sqliteDump(connectionId: string, options: SqliteBackupOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_dump", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function sqliteRestore(connectionId: string, options: SqliteRestoreOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_restore", { connectionId, options });
|
||||
}
|
||||
|
||||
export async function sqliteSync(options: SyncOptions): Promise<string> {
|
||||
return invoke<string>("sqlite_sync", { options });
|
||||
}
|
||||
|
||||
export async function exportSettings(): Promise<string> {
|
||||
return invoke<string>("export_settings");
|
||||
}
|
||||
|
||||
export async function importSettings(json: string): Promise<void> {
|
||||
return invoke<void>("import_settings", { json });
|
||||
}
|
||||
|
||||
// ─── Object Explorer (Functions, Triggers, Sequences, Enums, Extensions) ────
|
||||
|
||||
export async function getFunctions(connectionId: string, schema?: string): Promise<FunctionInfo[]> {
|
||||
|
||||
@@ -11,19 +11,19 @@ describe("dbCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("gives MySQL explorer/queries/editing/import/ddl but not objects/visualizer/tools", () => {
|
||||
it("gives MySQL explorer/queries/editing/import/ddl/tools but not objects/visualizer", () => {
|
||||
const c = DB_CAPABILITIES.mysql;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.tools).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.visualizer).toBe(false);
|
||||
expect(c.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl but not objects/tools", () => {
|
||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
|
||||
const c = DB_CAPABILITIES.sqlite;
|
||||
expect(c.explorer).toBe(true);
|
||||
expect(c.queries).toBe(true);
|
||||
@@ -31,8 +31,9 @@ describe("dbCapabilities", () => {
|
||||
expect(c.editing).toBe(true);
|
||||
expect(c.import).toBe(true);
|
||||
expect(c.ddl).toBe(true);
|
||||
expect(c.tools).toBe(true);
|
||||
expect(c.tableManagement).toBe(true);
|
||||
expect(c.objects).toBe(false);
|
||||
expect(c.tools).toBe(false);
|
||||
});
|
||||
|
||||
it("gives Redis nothing (connection+test only)", () => {
|
||||
@@ -79,14 +80,37 @@ describe("v0.7.7 capabilities", () => {
|
||||
expect(DB_CAPABILITIES.postgresql.roles).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tableManagement).toBe(true);
|
||||
});
|
||||
it("mysql/sqlite/redis disable maintenance, roles, tableManagement", () => {
|
||||
it("mysql/sqlite/redis disable maintenance and roles; only sqlite also gets tableManagement", () => {
|
||||
for (const t of ["mysql", "sqlite", "redis"] as const) {
|
||||
expect(DB_CAPABILITIES[t].maintenance).toBe(false);
|
||||
expect(DB_CAPABILITIES[t].roles).toBe(false);
|
||||
expect(DB_CAPABILITIES[t].tableManagement).toBe(false);
|
||||
}
|
||||
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
|
||||
expect(DB_CAPABILITIES.redis.tableManagement).toBe(false);
|
||||
});
|
||||
it("getCapabilities is safe for unknown types", () => {
|
||||
expect(getCapabilities("bogus").maintenance).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dbCapabilities v0.7.8", () => {
|
||||
it("enables tools for mysql and sqlite", () => {
|
||||
expect(DB_CAPABILITIES.mysql.tools).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.tools).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tools).toBe(true);
|
||||
});
|
||||
|
||||
it("enables tableManagement for sqlite", () => {
|
||||
expect(DB_CAPABILITIES.sqlite.tableManagement).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.tableManagement).toBe(false);
|
||||
});
|
||||
|
||||
it("still reports editing/import/ddl for mysql and sqlite", () => {
|
||||
expect(DB_CAPABILITIES.mysql.editing).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.import).toBe(true);
|
||||
expect(DB_CAPABILITIES.mysql.ddl).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.editing).toBe(true);
|
||||
expect(DB_CAPABILITIES.sqlite.objects).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,8 @@ const ALL_FALSE: DbCapabilities = {
|
||||
|
||||
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
|
||||
redis: { ...ALL_FALSE },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.7.7 docs coverage", () => {
|
||||
describe("v0.7.8 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,8 @@ describe("v0.7.7 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.7", () => {
|
||||
expect(readme).toContain("0.7.7");
|
||||
it("README declares v0.7.8", () => {
|
||||
expect(readme).toContain("0.7.8");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -59,9 +59,14 @@ describe("v0.7.7 docs coverage", () => {
|
||||
it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
|
||||
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.7 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.7/");
|
||||
expect(readme).toContain("Gridline_0.7.7_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.7-1.x86_64.rpm");
|
||||
it("AGENTS.md marks xlsx/SQLite-dump/cancel/settings-import complete", () => {
|
||||
expect(agents).toMatch(/Excel \(\.xlsx\) export \| ✅/);
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.8 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.8/");
|
||||
expect(readme).toContain("Gridline_0.7.8_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.8-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -26,4 +26,26 @@ describe("exportData", () => {
|
||||
exportData([[1, "a"]], columns, "json", "t");
|
||||
expect(click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("xlsx produces a Blob with the xlsx MIME and extension download", () => {
|
||||
const origCreateObjectURL = URL.createObjectURL;
|
||||
const origRevokeObjectURL = URL.revokeObjectURL;
|
||||
globalThis.URL.createObjectURL = vi.fn(() => "blob:x") as any;
|
||||
globalThis.URL.revokeObjectURL = vi.fn() as any;
|
||||
const a = { click: vi.fn(), href: "", download: "" };
|
||||
vi.spyOn(document, "createElement").mockReturnValue(a as any);
|
||||
const rows = [[1]];
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
try {
|
||||
exportData(rows, cols, "xlsx", "t");
|
||||
expect(a.download).toBe("t.xlsx");
|
||||
expect((URL.createObjectURL as any).mock.calls[0][0] instanceof Blob).toBe(true);
|
||||
expect((URL.createObjectURL as any).mock.calls[0][0].type).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
} finally {
|
||||
globalThis.URL.createObjectURL = origCreateObjectURL;
|
||||
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
export function exportData(
|
||||
@@ -11,6 +12,17 @@ export function exportData(
|
||||
let mime: string;
|
||||
|
||||
switch (format) {
|
||||
case "xlsx": {
|
||||
const bytes = buildXlsx(rows, columns);
|
||||
const blob = new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tableName}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
case "json": {
|
||||
const jsonRows = rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { isMacOS } from "./platform";
|
||||
|
||||
describe("isMacOS", () => {
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("navigator", undefined);
|
||||
});
|
||||
|
||||
it("true on MacIntel/Mac platform", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel", userAgent: "Mozilla/5.0 (Macintosh; X)" });
|
||||
expect(isMacOS()).toBe(true);
|
||||
});
|
||||
|
||||
it("false on Win32", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Win32", userAgent: "Mozilla/5.0 (Windows NT 10.0)" });
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
|
||||
it("false on Linux", () => {
|
||||
vi.stubGlobal("navigator", { platform: "Linux x86_64", userAgent: "Mozilla/5.0 (X11; Linux)" });
|
||||
expect(isMacOS()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** True only on macOS (the macOS "Overlay" drag strip is gated on this). Uses
|
||||
* navigator.platform/userAgent (the established pattern in BackupPage.tsx);
|
||||
* @tauri-apps/plugin-os is not installed and getCurrentWindow() has no osLabel(). */
|
||||
export function isMacOS(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const p = (navigator.platform || "").toLowerCase();
|
||||
const u = (navigator.userAgent || "").toLowerCase();
|
||||
return p.includes("mac") || u.includes("macintosh") || u.includes("mac os");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateSettingsExport } from "./settingsImport";
|
||||
import type { Settings } from "./types";
|
||||
|
||||
const good: Settings = {
|
||||
confirm_before_delete: true, default_folder_id: null, theme: "dark", font_size: "medium",
|
||||
default_ports: { postgresql: 5432 }, tag_order: null, table_refresh_rate: 5, table_page_size: 50,
|
||||
shortcuts: { open_command_palette: "Cmd+K" }, accent_color: "#2563EB",
|
||||
editor_font_size: 14, editor_font_family: "Menlo", editor_word_wrap: "off", editor_minimap: true, editor_tab_size: 2,
|
||||
};
|
||||
|
||||
describe("validateSettingsExport", () => {
|
||||
it("accepts a well-formed object", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: good });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid theme", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, theme: "purple" as never } });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.errors.some((e) => e.field === "theme")).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps editor_font_size to [8,24]", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, editor_font_size: 999 } });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.settings.editor_font_size).toBe(24);
|
||||
});
|
||||
|
||||
it("tolerates unknown keys (version skew)", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 99, settings: { ...good, futureField: true } } as never);
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a non-negative table_refresh_rate", () => {
|
||||
const r = validateSettingsExport({ schemaVersion: 1, settings: { ...good, table_refresh_rate: -1 } });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Settings } from "./types";
|
||||
|
||||
export interface SettingsExport { schemaVersion: number; settings: Settings }
|
||||
export type ValidationOk = { ok: true; settings: Settings };
|
||||
export type ValidationErr = { ok: false; errors: { field: string; message: string }[] };
|
||||
export type ValidationResult = ValidationOk | ValidationErr;
|
||||
|
||||
const THEMES = ["dark", "light", "system"];
|
||||
const FONT_SIZES = ["small", "medium", "large"];
|
||||
const FONT_FAMILIES = ["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"];
|
||||
const WORD_WRAPS = ["off", "on"];
|
||||
|
||||
function clamp(n: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, n)); }
|
||||
|
||||
export function validateSettingsExport(input: unknown): ValidationResult {
|
||||
const errors: ValidationErr["errors"] = [];
|
||||
if (typeof input !== "object" || input === null || !("settings" in input)) {
|
||||
return { ok: false, errors: [{ field: "settings", message: "missing settings object" }] };
|
||||
}
|
||||
const s = (input as { settings: Record<string, unknown> }).settings;
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
const enumCheck = (field: keyof Settings, allow: readonly string[], val: unknown) => {
|
||||
if (typeof val === "string" && allow.includes(val)) out[field] = val;
|
||||
else errors.push({ field, message: `invalid ${field}` });
|
||||
};
|
||||
enumCheck("theme", THEMES, s.theme);
|
||||
enumCheck("font_size", FONT_SIZES, s.font_size);
|
||||
enumCheck("editor_word_wrap", WORD_WRAPS, s.editor_word_wrap);
|
||||
|
||||
if (typeof s.accent_color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(s.accent_color)) out.accent_color = s.accent_color;
|
||||
else errors.push({ field: "accent_color", message: "invalid hex color" });
|
||||
|
||||
if (typeof s.editor_font_size === "number") out.editor_font_size = clamp(Math.trunc(s.editor_font_size), 8, 24);
|
||||
else errors.push({ field: "editor_font_size", message: "must be a number" });
|
||||
if (typeof s.editor_tab_size === "number") out.editor_tab_size = clamp(Math.trunc(s.editor_tab_size), 2, 8);
|
||||
else errors.push({ field: "editor_tab_size", message: "must be a number" });
|
||||
enumCheck("editor_font_family", FONT_FAMILIES, s.editor_font_family);
|
||||
if (typeof s.editor_minimap === "boolean") out.editor_minimap = s.editor_minimap;
|
||||
else errors.push({ field: "editor_minimap", message: "must be boolean" });
|
||||
|
||||
if (typeof s.table_page_size === "number" && s.table_page_size > 0) out.table_page_size = Math.trunc(s.table_page_size);
|
||||
else errors.push({ field: "table_page_size", message: "must be positive" });
|
||||
if (typeof s.table_refresh_rate === "number" && s.table_refresh_rate >= 0) out.table_refresh_rate = s.table_refresh_rate;
|
||||
else errors.push({ field: "table_refresh_rate", message: "must be non-negative" });
|
||||
|
||||
out.confirm_before_delete = typeof s.confirm_before_delete === "boolean" ? s.confirm_before_delete : true;
|
||||
out.default_folder_id = typeof s.default_folder_id === "string" || s.default_folder_id === null ? s.default_folder_id : null;
|
||||
out.tag_order = typeof s.tag_order === "string" || s.tag_order === null ? s.tag_order : null;
|
||||
out.default_ports = s.default_ports && typeof s.default_ports === "object" ? s.default_ports : {};
|
||||
out.shortcuts = s.shortcuts && typeof s.shortcuts === "object" ? s.shortcuts : {};
|
||||
|
||||
if (errors.length) return { ok: false, errors };
|
||||
return { ok: true, settings: out as unknown as Settings };
|
||||
}
|
||||
@@ -302,6 +302,7 @@ export interface SyncOptions {
|
||||
targetConnectionId: string;
|
||||
schema?: string;
|
||||
tables?: string[];
|
||||
dbType: DbType;
|
||||
}
|
||||
|
||||
export interface PgToolStatus {
|
||||
@@ -313,6 +314,49 @@ export interface PgToolStatus {
|
||||
pg_restore_source: string | null;
|
||||
}
|
||||
|
||||
// ─── Backup Types: MySQL / SQLite / Settings (v0.7.8) ───────────
|
||||
// NOTE: the Rust `MySqlToolStatus` model is `#[serde(rename_all = "camelCase")]`,
|
||||
// so these interfaces use camelCase keys to match the actual IPC payloads.
|
||||
|
||||
export interface MySqlToolStatus {
|
||||
mysqldumpFound: boolean;
|
||||
mysqlFound: boolean;
|
||||
mysqldumpVersion: string | null;
|
||||
mysqlVersion: string | null;
|
||||
mysqldumpSource: string | null;
|
||||
mysqlSource: string | null;
|
||||
}
|
||||
|
||||
export interface MySqlBackupOptions {
|
||||
database: string;
|
||||
filePath: string;
|
||||
singleTransaction: boolean;
|
||||
noData: boolean;
|
||||
routines: boolean;
|
||||
triggers: boolean;
|
||||
events: boolean;
|
||||
}
|
||||
|
||||
export interface MySqlRestoreOptions {
|
||||
database: string;
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface SqliteBackupOptions {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface SqliteRestoreOptions {
|
||||
filePath: string;
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsExport {
|
||||
schemaVersion: number;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export type PgObjectType =
|
||||
| "table" | "view" | "materialized view" | "function" | "procedure"
|
||||
| "trigger" | "sequence" | "enum" | "extension" | "index" | "constraint";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.7 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.7");
|
||||
it("declares v0.7.8 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.8");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { unzipSync } from "fflate";
|
||||
import { buildXlsx } from "./xlsx";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
function sheetXML(out: Uint8Array): string {
|
||||
const files = unzipSync(out);
|
||||
return new TextDecoder().decode(files["xl/worksheets/sheet1.xml"]);
|
||||
}
|
||||
|
||||
describe("buildXlsx", () => {
|
||||
it("emits headers + rows as inline strings", () => {
|
||||
const out = buildXlsx([[1, "Alice"], [2, "Bob"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain('t="inlineStr"');
|
||||
expect(xml).toContain("id");
|
||||
expect(xml).toContain("Alice");
|
||||
expect(xml).toContain("Bob");
|
||||
});
|
||||
|
||||
it("escapes XML-special characters in cell text", () => {
|
||||
const out = buildXlsx([[1, "a<b>&c"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("a<b>&c");
|
||||
expect(xml).not.toContain("a<b>&c");
|
||||
});
|
||||
|
||||
it("emits formula-triggering values as inline strings (no formula evaluation)", () => {
|
||||
const out = buildXlsx([[1, "=1+1"], [2, "+5"], [3, "@SUM"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("=1+1");
|
||||
expect(xml).not.toMatch(/<c[^>]*?><f>/);
|
||||
});
|
||||
|
||||
it("declares a column width per header", () => {
|
||||
const out = buildXlsx([[1, "x"]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("<cols>");
|
||||
expect(xml).toContain("width=");
|
||||
});
|
||||
|
||||
it("renders null cells as empty inline strings", () => {
|
||||
const out = buildXlsx([[1, null]], cols);
|
||||
const xml = sheetXML(out);
|
||||
expect(xml).toContain("inlineStr");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
/** Minimal OOXML spreadsheet: one sheet, every cell as an inline string
|
||||
* (t="inlineStr") so Excel never evaluates a cell as a formula — the
|
||||
* formula-injection mitigation required by the spec. */
|
||||
const XML_ESCAPES: [RegExp, string][] = [
|
||||
[/&/g, "&"],
|
||||
[/</g, "<"],
|
||||
[/>/g, ">"],
|
||||
[/"/g, """],
|
||||
];
|
||||
function esc(s: string): string {
|
||||
for (const [re, w] of XML_ESCAPES) s = s.replace(re, w);
|
||||
return s;
|
||||
}
|
||||
function colLetter(n: number): string {
|
||||
let s = "";
|
||||
for (let i = n; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildColsXML(columns: ColumnInfo[]): string {
|
||||
const maxW = columns.map((c) => Math.min(60, Math.max(8, c.name.length + 2)));
|
||||
return `<cols>${maxW.map((w, i) => `\n <col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>`).join("")}\n</cols>`;
|
||||
}
|
||||
|
||||
function buildCellsXML(rows: unknown[][], columns: ColumnInfo[]): string {
|
||||
let cells = "";
|
||||
cells += `<row r="1">` + columns
|
||||
.map((c, i) => `<c r="${colLetter(i + 1)}1" t="inlineStr"><is><t>${esc(c.name)}</t></is></c>`)
|
||||
.join("") + `</row>`;
|
||||
rows.forEach((row, rIdx) => {
|
||||
const r = rIdx + 2;
|
||||
cells += `<row r="${r}">` + row
|
||||
.map((v, i) => {
|
||||
const ref = `${colLetter(i + 1)}${r}`;
|
||||
const t = v === null || v === undefined ? "" : String(v);
|
||||
return `<c r="${ref}" t="inlineStr"><is><t>${esc(t)}</t></is></c>`;
|
||||
})
|
||||
.join("") + `</row>`;
|
||||
});
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function buildXlsx(rows: unknown[][], columns: ColumnInfo[]): Uint8Array {
|
||||
const colsXML = buildColsXML(columns);
|
||||
const cells = buildCellsXML(rows, columns);
|
||||
|
||||
const sheet1 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">${colsXML}<sheetData>${cells}</sheetData></worksheet>`;
|
||||
const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
||||
const ct = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>`;
|
||||
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`;
|
||||
const rootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`;
|
||||
|
||||
return zipSync({
|
||||
"[Content_Types].xml": strToU8(ct),
|
||||
"_rels/.rels": strToU8(rootRels),
|
||||
"xl/workbook.xml": strToU8(workbook),
|
||||
"xl/_rels/workbook.xml.rels": strToU8(rels),
|
||||
"xl/worksheets/sheet1.xml": strToU8(sheet1),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user