feat(import): add table import wizard

This commit is contained in:
t8y2 committed 2026-07-06 00:01:41 +08:00
1 parent 4b6a588354
commit 4666f8a5f2
16 files changed
+1871 -205

No files matched your search

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { autoMapImportColumns, nextTableImportWizardStep, previousTableImportWizardStep, requiredImportTargetColumns, validateImportMappings } from "@/lib/table/tableImport";
describe("tableImport", () => {
it("auto maps exact and normalized column names", () => {
expect(autoMapImportColumns(["id", "user name", "ignored"], ["id", "user_name"])).toEqual({
id: "id",
"user name": "user_name",
ignored: "",
});
});
it("rejects empty mappings and duplicate target columns", () => {
expect(validateImportMappings([])).toEqual({
valid: false,
errors: ["No columns mapped for import"],
duplicateTargets: [],
});
const result = validateImportMappings([
{ sourceColumn: "a", targetColumn: "name" },
{ sourceColumn: "b", targetColumn: "NAME" },
]);
expect(result.valid).toBe(false);
expect(result.duplicateTargets).toEqual(["NAME"]);
expect(result.errors[0]).toContain("Target column mapped more than once");
});
it("detects unmapped required target columns", () => {
expect(
requiredImportTargetColumns(
[
{ name: "id", is_nullable: false, column_default: null, extra: "auto_increment" },
{ name: "name", is_nullable: false, column_default: null },
{ name: "created_at", is_nullable: false, column_default: "CURRENT_TIMESTAMP" },
],
["id"],
),
).toEqual(["name"]);
});
it("moves through wizard steps with bounds", () => {
expect(nextTableImportWizardStep("source")).toBe("options");
expect(nextTableImportWizardStep("execution")).toBe("execution");
expect(previousTableImportWizardStep("review")).toBe("mapping");
expect(previousTableImportWizardStep("source")).toBe("source");
});
});
+4
View File
@@ -501,7 +501,11 @@ export type {
TransferTableNameCase,
TableImportMode,
TableImportStatus,
TableImportSourceFormat,
TableImportJsonShape,
TableImportColumnMapping,
TableImportParseOptions,
TableImportPreviewRequest,
TableImportPreview,
TableImportRequest,
TableImportSummary,
+18 -2
View File
@@ -74,6 +74,7 @@ import type {
SqlFileProgress,
TransferRequest,
TransferProgress,
TableImportPreviewRequest,
TableImportPreview,
TableImportRequest,
TableImportSummary,
@@ -1313,12 +1314,27 @@ export async function sortTablesByFkDependency(options: SortTablesByFkOptions):
// Table File Import
// ---------------------------------------------------------------------------
export async function previewTableImportFile(fileOrPath: string | File): Promise<TableImportPreview> {
export async function previewTableImportFile(fileOrPath: string | File | TableImportPreviewRequest, options: Partial<TableImportPreviewRequest> = {}): Promise<TableImportPreview> {
if (typeof fileOrPath === "object" && !(fileOrPath instanceof File)) {
throw new Error("previewTableImportFile in web mode requires a File object for upload previews");
}
if (typeof fileOrPath === "string") {
throw new Error("previewTableImportFile in web mode requires a File object, not a file path");
if (!options.sourceRef) {
throw new Error("previewTableImportFile in web mode requires a File object for new uploads");
}
const res = await fetch(apiUrl("/api/import/preview"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request: { ...options, filePath: fileOrPath } }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
const formData = new FormData();
formData.append("file", fileOrPath);
if (options.sourceFormat) formData.append("sourceFormat", options.sourceFormat);
if (options.parseOptions) formData.append("parseOptions", JSON.stringify(options.parseOptions));
if (options.previewLimit != null) formData.append("previewLimit", String(options.previewLimit));
const res = await fetch(apiUrl("/api/import/preview"), { method: "POST", body: formData });
if (!res.ok) throw new Error(await res.text());
return res.json();
+31 -2
View File
@@ -1898,20 +1898,42 @@ export async function sortTablesByFkDependency(options: SortTablesByFkOptions):
// --- Table File Import ---
export type TableImportMode = "append" | "truncate";
export type TableImportStatus = "running" | "done" | "error" | "cancelled";
export type TableImportSourceFormat = "csv" | "tsv" | "delimited" | "json" | "excel";
export type TableImportJsonShape = "auto" | "objects" | "arrays";
export interface TableImportColumnMapping {
sourceColumn: string;
targetColumn: string;
}
export interface TableImportParseOptions {
delimiter?: string | null;
hasHeader?: boolean | null;
trimValues?: boolean | null;
emptyStringAsNull?: boolean | null;
sheetName?: string | null;
sheetIndex?: number | null;
jsonShape?: TableImportJsonShape | null;
}
export interface TableImportPreviewRequest {
filePath: string;
sourceRef?: string | null;
sourceFormat?: TableImportSourceFormat | null;
parseOptions?: TableImportParseOptions | null;
previewLimit?: number | null;
}
export interface TableImportPreview {
fileName: string;
filePath: string;
sourceRef?: string | null;
fileType: string;
sizeBytes: number;
columns: string[];
rows: unknown[][];
totalRows: number;
sheets?: string[];
}
export interface TableImportRequest {
@@ -1921,6 +1943,9 @@ export interface TableImportRequest {
schema: string;
table: string;
filePath: string;
sourceRef?: string | null;
sourceFormat?: TableImportSourceFormat | null;
parseOptions?: TableImportParseOptions | null;
mappings: TableImportColumnMapping[];
mode: TableImportMode;
batchSize: number;
@@ -1940,8 +1965,12 @@ export interface TableImportProgress {
error?: string | null;
}
export async function previewTableImportFile(filePath: string): Promise<TableImportPreview> {
return invoke("preview_table_import_file", { filePath });
export async function previewTableImportFile(filePathOrRequest: string | File | TableImportPreviewRequest, options: Partial<TableImportPreviewRequest> = {}): Promise<TableImportPreview> {
if (typeof filePathOrRequest !== "string" && !("filePath" in filePathOrRequest)) {
throw new Error("previewTableImportFile in desktop mode requires a file path, not a File object");
}
const request: TableImportPreviewRequest = typeof filePathOrRequest === "string" ? { ...options, filePath: filePathOrRequest } : filePathOrRequest;
return invoke("preview_table_import_file", { request });
}
export async function importTableFile(request: TableImportRequest, onProgress: (progress: TableImportProgress) => void): Promise<TableImportSummary> {
+71
View File
@@ -1,5 +1,28 @@
export const IMPORT_SKIP_TARGET = "";
export interface ImportColumnMappingLike {
sourceColumn: string;
targetColumn: string;
}
export interface ImportMappingValidationResult {
valid: boolean;
errors: string[];
duplicateTargets: string[];
}
export interface ImportTargetColumnLike {
name: string;
is_nullable?: boolean;
column_default?: string | null;
extra?: string | null;
is_primary_key?: boolean;
}
export type TableImportWizardStep = "source" | "options" | "mapping" | "review" | "execution";
export const TABLE_IMPORT_WIZARD_STEPS: TableImportWizardStep[] = ["source", "options", "mapping", "review", "execution"];
export function normalizeImportColumnName(name: string): string {
return name.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
}
@@ -10,3 +33,51 @@ export function autoMapImportColumns(sourceColumns: string[], targetColumns: str
return Object.fromEntries(sourceColumns.map((source) => [source, exactTargets.get(source) ?? normalizedTargets.get(normalizeImportColumnName(source)) ?? IMPORT_SKIP_TARGET]));
}
export function validateImportMappings(mappings: ImportColumnMappingLike[]): ImportMappingValidationResult {
const activeMappings = mappings.filter((mapping) => mapping.targetColumn.trim());
const errors: string[] = [];
const duplicateTargets: string[] = [];
if (activeMappings.length === 0) {
errors.push("No columns mapped for import");
}
const seen = new Set<string>();
for (const mapping of activeMappings) {
const key = mapping.targetColumn.trim().toLowerCase();
if (seen.has(key) && !duplicateTargets.includes(mapping.targetColumn)) {
duplicateTargets.push(mapping.targetColumn);
}
seen.add(key);
}
if (duplicateTargets.length) {
errors.push(`Target column mapped more than once: ${duplicateTargets.join(", ")}`);
}
return { valid: errors.length === 0, errors, duplicateTargets };
}
export function requiredImportTargetColumns(columns: ImportTargetColumnLike[], mappedTargetColumns: string[]): string[] {
const mapped = new Set(mappedTargetColumns.map((column) => column.toLowerCase()));
return columns
.filter((column) => !mapped.has(column.name.toLowerCase()))
.filter(
(column) =>
column.is_nullable === false &&
!column.column_default &&
!String(column.extra || "")
.toLowerCase()
.includes("auto"),
)
.map((column) => column.name);
}
export function nextTableImportWizardStep(step: TableImportWizardStep): TableImportWizardStep {
const index = TABLE_IMPORT_WIZARD_STEPS.indexOf(step);
return TABLE_IMPORT_WIZARD_STEPS[Math.min(TABLE_IMPORT_WIZARD_STEPS.length - 1, Math.max(0, index) + 1)];
}
export function previousTableImportWizardStep(step: TableImportWizardStep): TableImportWizardStep {
const index = TABLE_IMPORT_WIZARD_STEPS.indexOf(step);
return TABLE_IMPORT_WIZARD_STEPS[Math.max(0, index - 1)];
}