feat(import): add table import wizard

This commit is contained in:
t8y2 2026-07-06 00:00:57 +08:00
parent 4b6a588354
commit 4666f8a5f2
16 changed files with 1871 additions and 205 deletions

View File

@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Check, FileUp, Loader2, Square, Upload, X } from "@lucide/vue";
import { AlertTriangle, ArrowLeft, ArrowRight, Check, CheckCircle2, FileJson, FileSpreadsheet, FileText, FileUp, Loader2, RefreshCw, Square, Upload, X } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { autoMapImportColumns } from "@/lib/table/tableImport";
import { autoMapImportColumns, nextTableImportWizardStep, previousTableImportWizardStep, requiredImportTargetColumns, validateImportMappings, type TableImportWizardStep } from "@/lib/table/tableImport";
import type { ColumnInfo } from "@/types/database";
import * as api from "@/lib/backend/api";
@ -29,6 +29,8 @@ const props = defineProps<{
const SKIP_VALUE = "__skip__";
const targetColumns = ref<ColumnInfo[]>([]);
const selectedSource = ref<string | File | null>(null);
const sourceFormat = ref<api.TableImportSourceFormat>("csv");
const preview = ref<api.TableImportPreview | null>(null);
const columnMapping = ref<Record<string, string>>({});
const loadingTarget = ref(false);
@ -40,7 +42,32 @@ const cancelling = ref(false);
const importId = ref("");
const progress = ref<api.TableImportProgress | null>(null);
const errorMessage = ref("");
const wizardStep = ref<TableImportWizardStep>("source");
const fileInput = ref<HTMLInputElement | null>(null);
const delimiter = ref(",");
const hasHeader = ref(true);
const trimValues = ref(false);
const emptyStringAsNull = ref(true);
const selectedSheet = ref("");
const jsonShape = ref<api.TableImportJsonShape>("auto");
const previewLimit = ref(50);
let previewReloadTimer: ReturnType<typeof setTimeout> | null = null;
const formatOptions: Array<{ value: api.TableImportSourceFormat; icon: any; labelKey: string; descriptionKey: string }> = [
{ value: "csv", icon: FileText, labelKey: "tableImport.formatCsv", descriptionKey: "tableImport.formatCsvDescription" },
{ value: "tsv", icon: FileText, labelKey: "tableImport.formatTsv", descriptionKey: "tableImport.formatTsvDescription" },
{ value: "delimited", icon: FileText, labelKey: "tableImport.formatDelimited", descriptionKey: "tableImport.formatDelimitedDescription" },
{ value: "json", icon: FileJson, labelKey: "tableImport.formatJson", descriptionKey: "tableImport.formatJsonDescription" },
{ value: "excel", icon: FileSpreadsheet, labelKey: "tableImport.formatExcel", descriptionKey: "tableImport.formatExcelDescription" },
];
const wizardSteps: Array<{ value: TableImportWizardStep; labelKey: string }> = [
{ value: "source", labelKey: "tableImport.stepSource" },
{ value: "options", labelKey: "tableImport.stepOptions" },
{ value: "mapping", labelKey: "tableImport.stepMapping" },
{ value: "review", labelKey: "tableImport.stepReview" },
{ value: "execution", labelKey: "tableImport.stepExecution" },
];
const selectedConnection = computed(() => (props.prefillConnectionId ? store.getConfig(props.prefillConnectionId) : undefined));
const targetColumnNames = computed(() => targetColumns.value.map((column) => column.name));
@ -55,7 +82,21 @@ const mappedColumns = computed<api.TableImportColumnMapping[]>(() => {
.filter((mapping) => mapping.targetColumn);
});
const mappedCount = computed(() => mappedColumns.value.length);
const canImport = computed(() => !!preview.value && !!props.prefillConnectionId && !!props.prefillTable && mappedColumns.value.length > 0 && !running.value);
const mappingValidation = computed(() => validateImportMappings(mappedColumns.value));
const requiredUnmappedColumns = computed(() =>
requiredImportTargetColumns(
targetColumns.value,
mappedColumns.value.map((mapping) => mapping.targetColumn),
),
);
const canImport = computed(() => !!preview.value && !!props.prefillConnectionId && !!props.prefillTable && mappingValidation.value.valid && !running.value);
const canGoBack = computed(() => wizardStep.value !== "source" && wizardStep.value !== "execution" && !running.value);
const canGoNext = computed(() => {
if (wizardStep.value === "source") return !!selectedSource.value && !!sourceFormat.value;
if (wizardStep.value === "options") return !!preview.value;
if (wizardStep.value === "mapping") return mappingValidation.value.valid;
return false;
});
const progressPercent = computed(() => {
const p = progress.value;
if (!p || p.totalRows <= 0) return 0;
@ -65,9 +106,32 @@ const targetLabel = computed(() => {
const pieces = [selectedConnection.value?.name, props.prefillDatabase, props.prefillSchema, props.prefillTable].filter(Boolean);
return pieces.join(" / ");
});
const selectedSourceName = computed(() => {
const source = selectedSource.value;
if (!source) return "";
return typeof source === "string" ? source.split(/[\\/]/).pop() || source : source.name;
});
const parseOptions = computed<api.TableImportParseOptions>(() => ({
delimiter: sourceFormat.value === "tsv" ? "\\t" : sourceFormat.value === "csv" ? "," : delimiter.value,
hasHeader: hasHeader.value,
trimValues: trimValues.value,
emptyStringAsNull: emptyStringAsNull.value,
sheetName: sourceFormat.value === "excel" ? selectedSheet.value || null : null,
jsonShape: sourceFormat.value === "json" ? jsonShape.value : null,
}));
const terminalStatus = computed(() => progress.value?.status && ["done", "error", "cancelled"].includes(progress.value.status));
function resetState() {
targetColumns.value = [];
selectedSource.value = null;
sourceFormat.value = "csv";
delimiter.value = ",";
hasHeader.value = true;
trimValues.value = false;
emptyStringAsNull.value = true;
selectedSheet.value = "";
jsonShape.value = "auto";
previewLimit.value = 50;
preview.value = null;
columnMapping.value = {};
importMode.value = "append";
@ -77,6 +141,16 @@ function resetState() {
importId.value = "";
progress.value = null;
errorMessage.value = "";
wizardStep.value = "source";
}
function detectFormat(name: string): api.TableImportSourceFormat {
const lower = name.toLowerCase();
if (lower.endsWith(".tsv")) return "tsv";
if (lower.endsWith(".txt")) return "delimited";
if (lower.endsWith(".json")) return "json";
if (lower.endsWith(".xls") || lower.endsWith(".xlsx") || lower.endsWith(".xlsm")) return "excel";
return "csv";
}
function applyAutoMapping() {
@ -101,18 +175,23 @@ async function loadTargetColumns() {
}
async function previewSelectedImportFile(fileOrPath: string | File) {
if (isTauriRuntime()) {
return api.previewTableImportFile(fileOrPath as string);
}
const { previewTableImportFile } = await import("@/lib/backend/http");
return previewTableImportFile(fileOrPath as File);
return api.previewTableImportFile(fileOrPath, {
sourceFormat: sourceFormat.value,
parseOptions: parseOptions.value,
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
});
}
async function loadPreview(fileOrPath: string | File) {
async function loadPreview(fileOrPath = selectedSource.value) {
if (!fileOrPath) return;
loadingPreview.value = true;
errorMessage.value = "";
try {
preview.value = await previewSelectedImportFile(fileOrPath);
const nextPreview = await previewSelectedImportFile(fileOrPath);
preview.value = nextPreview;
if (sourceFormat.value === "excel" && !selectedSheet.value && nextPreview.sheets?.length) {
selectedSheet.value = nextPreview.sheets[0];
}
applyAutoMapping();
} catch (e: any) {
preview.value = null;
@ -123,6 +202,19 @@ async function loadPreview(fileOrPath: string | File) {
}
}
function assignSelectedSource(source: string | File) {
selectedSource.value = source;
preview.value = null;
columnMapping.value = {};
progress.value = null;
errorMessage.value = "";
const name = typeof source === "string" ? source : source.name;
sourceFormat.value = detectFormat(name);
delimiter.value = sourceFormat.value === "tsv" ? "\\t" : ",";
selectedSheet.value = "";
wizardStep.value = "options";
}
async function selectFile() {
if (!isTauriRuntime()) {
fileInput.value?.click();
@ -132,15 +224,14 @@ async function selectFile() {
const selected = await open({
multiple: false,
filters: [
{ name: "Data files", extensions: ["csv", "tsv", "json", "xlsx", "xlsm", "xls"] },
{ name: "CSV", extensions: ["csv", "tsv"] },
{ name: "Data files", extensions: ["csv", "tsv", "txt", "json", "xlsx", "xlsm", "xls"] },
{ name: "Text", extensions: ["csv", "tsv", "txt"] },
{ name: "JSON", extensions: ["json"] },
{ name: "Excel", extensions: ["xlsx", "xlsm", "xls"] },
],
});
if (!selected || Array.isArray(selected)) return;
await loadPreview(selected);
assignSelectedSource(selected);
}
async function handleFileInputChange(event: Event) {
@ -148,7 +239,7 @@ async function handleFileInputChange(event: Event) {
const file = input.files?.[0];
input.value = "";
if (!file || running.value) return;
await loadPreview(file);
assignSelectedSource(file);
}
function updateMapping(sourceColumn: string, value: any) {
@ -165,12 +256,34 @@ function formatCell(value: unknown) {
return String(value);
}
function goBack() {
wizardStep.value = previousTableImportWizardStep(wizardStep.value);
}
function canOpenStep(step: TableImportWizardStep) {
if (running.value || step === "execution") return false;
if (step === "source") return true;
if (step === "options") return !!selectedSource.value;
if (step === "mapping") return !!preview.value;
if (step === "review") return !!preview.value && mappingValidation.value.valid;
return false;
}
async function goNext() {
if (wizardStep.value === "options" && !preview.value) {
await loadPreview();
if (!preview.value) return;
}
wizardStep.value = nextTableImportWizardStep(wizardStep.value);
}
async function startImport() {
const currentPreview = preview.value;
if (!canImport.value || !currentPreview || !props.prefillConnectionId || !props.prefillTable) return;
running.value = true;
cancelling.value = false;
errorMessage.value = "";
wizardStep.value = "execution";
importId.value = uuid();
progress.value = {
importId: importId.value,
@ -188,6 +301,9 @@ async function startImport() {
schema: props.prefillSchema || "",
table: props.prefillTable,
filePath: currentPreview.filePath,
sourceRef: currentPreview.sourceRef || null,
sourceFormat: sourceFormat.value,
parseOptions: parseOptions.value,
mappings: mappedColumns.value,
mode: importMode.value,
batchSize: Math.max(1, Number(batchSize.value) || 500),
@ -196,11 +312,19 @@ async function startImport() {
progress.value = nextProgress;
},
);
progress.value = { importId: summary.importId, status: "done", rowsImported: summary.rowsImported, totalRows: summary.totalRows };
toast(t("tableImport.success", { count: summary.rowsImported }), 2500);
store.invalidateMetadataCache(props.prefillConnectionId, props.prefillDatabase || "", props.prefillSchema || undefined, props.prefillTable);
open.value = false;
} catch (e: any) {
errorMessage.value = String(e?.message || e);
const message = String(e?.message || e);
errorMessage.value = message;
progress.value = {
importId: importId.value,
status: progress.value?.status === "cancelled" ? "cancelled" : "error",
rowsImported: progress.value?.rowsImported ?? 0,
totalRows: progress.value?.totalRows ?? currentPreview.totalRows,
error: message,
};
} finally {
running.value = false;
cancelling.value = false;
@ -213,6 +337,14 @@ async function cancelImport() {
await api.cancelTableImport(importId.value);
}
function schedulePreviewReload() {
if (!preview.value || !selectedSource.value || loadingPreview.value || running.value) return;
if (previewReloadTimer) clearTimeout(previewReloadTimer);
previewReloadTimer = setTimeout(() => {
void loadPreview();
}, 250);
}
watch(
open,
(value) => {
@ -223,13 +355,15 @@ watch(
},
{ immediate: true },
);
watch([sourceFormat, delimiter, hasHeader, trimValues, emptyStringAsNull, selectedSheet, jsonShape, previewLimit], schedulePreviewReload);
</script>
<template>
<Dialog v-model:open="open">
<DialogScrollContent class="sm:max-w-[760px]" :trap-focus="false" @interact-outside.prevent>
<DialogScrollContent class="sm:max-w-[980px] pt-12" :trap-focus="false" @interact-outside.prevent>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<DialogTitle class="flex items-center gap-2 text-base">
<FileUp class="h-4 w-4" />
{{ t("tableImport.title") }}
</DialogTitle>
@ -237,7 +371,7 @@ watch(
<div class="space-y-4 py-2">
<div class="grid grid-cols-[1fr_auto] gap-2">
<input ref="fileInput" type="file" accept=".csv,.tsv,.json,.xlsx,.xlsm,.xls" class="hidden" @change="handleFileInputChange" />
<input ref="fileInput" type="file" accept=".csv,.tsv,.txt,.json,.xlsx,.xlsm,.xls" class="hidden" @change="handleFileInputChange" />
<div class="min-w-0 rounded-md border bg-muted/20 px-3 py-2">
<div class="truncate text-xs text-muted-foreground">{{ t("tableImport.target") }}</div>
<div class="truncate text-sm font-medium">
@ -247,96 +381,257 @@ watch(
<Button variant="outline" size="sm" :disabled="running || loadingPreview" @click="selectFile">
<Loader2 v-if="loadingPreview" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Upload v-else class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.selectFile") }}
{{ selectedSource ? t("tableImport.changeFile") : t("tableImport.selectFile") }}
</Button>
</div>
<div v-if="preview" class="grid grid-cols-3 gap-2 text-xs">
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.file") }}</div>
<div class="truncate font-medium">{{ preview.fileName }}</div>
<div class="grid grid-cols-5 gap-1 rounded-md border bg-muted/20 p-1">
<button v-for="step in wizardSteps" :key="step.value" type="button" class="h-8 rounded px-2 text-xs font-medium" :class="wizardStep === step.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground'" :disabled="!canOpenStep(step.value)" @click="wizardStep = step.value">
{{ t(step.labelKey) }}
</button>
</div>
<div v-if="wizardStep === 'source'" class="space-y-4">
<div class="rounded-md border border-dashed p-6 text-center">
<FileUp class="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
<div class="text-sm font-medium">{{ selectedSourceName || t("tableImport.noFileSelected") }}</div>
<Button class="mt-4" size="sm" @click="selectFile">
<Upload class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.selectFile") }}
</Button>
</div>
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.rows") }}</div>
<div class="font-medium">{{ preview.totalRows.toLocaleString() }}</div>
</div>
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.mapped") }}</div>
<div class="font-medium">{{ mappedCount }} / {{ preview.columns.length }}</div>
<div class="grid grid-cols-5 gap-2">
<button v-for="format in formatOptions" :key="format.value" type="button" class="min-h-20 rounded-md border px-3 py-2 text-left" :class="sourceFormat === format.value ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'" @click="sourceFormat = format.value">
<component :is="format.icon" class="mb-2 h-4 w-4 text-muted-foreground" />
<div class="text-xs font-medium">{{ t(format.labelKey) }}</div>
<div class="mt-1 text-[11px] leading-snug text-muted-foreground">{{ t(format.descriptionKey) }}</div>
</button>
</div>
</div>
<div v-if="preview" class="grid grid-cols-[minmax(220px,280px)_1fr] gap-3">
<div class="rounded-md border">
<div class="border-b px-3 py-2 text-xs font-medium">{{ t("tableImport.mapping") }}</div>
<div class="max-h-[280px] overflow-auto p-2">
<div v-for="sourceColumn in preview.columns" :key="sourceColumn" class="grid grid-cols-[1fr_1fr] items-center gap-2 py-1">
<div class="truncate font-mono text-xs" :title="sourceColumn">
{{ sourceColumn }}
</div>
<Select :model-value="columnMapping[sourceColumn] || SKIP_VALUE" @update:model-value="(value: any) => updateMapping(sourceColumn, value)">
<SelectTrigger class="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="SKIP_VALUE">{{ t("tableImport.skipColumn") }}</SelectItem>
<SelectItem v-for="column in targetColumns" :key="column.name" :value="column.name">
{{ column.name }}
</SelectItem>
</SelectContent>
</Select>
<div v-else-if="wizardStep === 'options'" class="space-y-4">
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.sourceFormat") }}</Label>
<Select :model-value="sourceFormat" @update:model-value="(value: any) => (sourceFormat = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="format in formatOptions" :key="format.value" :value="format.value">
{{ t(format.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.previewRows") }}</Label>
<Input v-model.number="previewLimit" type="number" min="1" max="500" class="h-8 text-xs" />
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.sourceFile") }}</Label>
<div class="flex h-8 items-center rounded-md border px-2 text-xs">
<span class="truncate">{{ selectedSourceName || t("tableImport.noFileSelected") }}</span>
</div>
</div>
</div>
<div class="min-w-0 rounded-md border">
<div class="border-b px-3 py-2 text-xs font-medium">{{ t("tableImport.preview") }}</div>
<div class="max-h-[280px] overflow-auto">
<table class="min-w-full border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 bg-background">
<tr>
<th v-for="column in preview.columns" :key="column" class="border-b border-r px-2 py-1.5 text-left font-medium">
<span class="block max-w-[140px] truncate">{{ column }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in preview.rows" :key="rowIndex">
<td v-for="(cell, colIndex) in row" :key="colIndex" class="max-w-[180px] border-b border-r px-2 py-1.5 font-mono" :class="{ 'text-muted-foreground': cell === null }">
<span class="block truncate">{{ formatCell(cell) }}</span>
</td>
</tr>
</tbody>
</table>
<div v-if="sourceFormat === 'csv' || sourceFormat === 'tsv' || sourceFormat === 'delimited'" class="grid grid-cols-4 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.delimiter") }}</Label>
<Input v-model="delimiter" :disabled="sourceFormat !== 'delimited'" class="h-8 text-xs font-mono" />
</div>
<label class="flex items-center gap-2 text-xs">
<input v-model="hasHeader" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
{{ t("tableImport.hasHeader") }}
</label>
<label class="flex items-center gap-2 text-xs">
<input v-model="trimValues" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
{{ t("tableImport.trimValues") }}
</label>
<label class="flex items-center gap-2 text-xs">
<input v-model="emptyStringAsNull" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
{{ t("tableImport.emptyStringAsNull") }}
</label>
</div>
<div v-else-if="sourceFormat === 'json'" class="grid grid-cols-2 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.jsonShape") }}</Label>
<Select :model-value="jsonShape" @update:model-value="(value: any) => (jsonShape = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{{ t("tableImport.jsonShapeAuto") }}</SelectItem>
<SelectItem value="objects">{{ t("tableImport.jsonShapeObjects") }}</SelectItem>
<SelectItem value="arrays">{{ t("tableImport.jsonShapeArrays") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div v-else-if="sourceFormat === 'excel'" class="grid grid-cols-3 gap-3 rounded-md border p-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.sheet") }}</Label>
<Select :model-value="selectedSheet" :disabled="!preview?.sheets?.length" @update:model-value="(value: any) => (selectedSheet = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('tableImport.firstSheet')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="sheet in preview?.sheets || []" :key="sheet" :value="sheet">{{ sheet }}</SelectItem>
</SelectContent>
</Select>
</div>
<label class="flex items-center gap-2 text-xs">
<input v-model="hasHeader" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
{{ t("tableImport.hasHeader") }}
</label>
</div>
<div class="flex items-center gap-2">
<Button size="sm" :disabled="!selectedSource || loadingPreview" @click="loadPreview()">
<Loader2 v-if="loadingPreview" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="mr-1.5 h-3.5 w-3.5" />
{{ preview ? t("tableImport.reloadPreview") : t("tableImport.loadPreview") }}
</Button>
<span v-if="preview" class="text-xs text-muted-foreground">{{ t("tableImport.previewReady", { rows: preview.totalRows, columns: preview.columns.length }) }}</span>
</div>
</div>
<div v-if="preview" class="grid grid-cols-3 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.mode") }}</Label>
<Select :model-value="importMode" @update:model-value="(value: any) => (importMode = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append">{{ t("tableImport.append") }}</SelectItem>
<SelectItem value="truncate">{{ t("tableImport.truncate") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.batchSize") }}</Label>
<Input v-model.number="batchSize" type="number" min="1" class="h-8 text-xs" />
</div>
<div v-if="running || progress" class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.progress") }}</Label>
<div class="h-8 rounded-md border px-2 text-xs flex items-center gap-2">
<Loader2 v-if="running && !cancelling" class="h-3.5 w-3.5 animate-spin text-primary" />
<Square v-else-if="cancelling" class="h-3.5 w-3.5 fill-current text-destructive" />
<Check v-else class="h-3.5 w-3.5 text-emerald-600" />
<span class="truncate"> {{ progress?.rowsImported ?? 0 }} / {{ progress?.totalRows ?? preview.totalRows }} · {{ progressPercent }}% </span>
<div v-else-if="wizardStep === 'mapping'" class="space-y-3">
<div v-if="preview" class="grid grid-cols-3 gap-2 text-xs">
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.file") }}</div>
<div class="truncate font-medium">{{ preview.fileName }}</div>
</div>
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.rows") }}</div>
<div class="font-medium">{{ preview.totalRows.toLocaleString() }}</div>
</div>
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.mapped") }}</div>
<div class="font-medium">{{ mappedCount }} / {{ preview.columns.length }}</div>
</div>
</div>
<div v-if="preview" class="grid grid-cols-[minmax(240px,300px)_1fr] gap-3">
<div class="rounded-md border">
<div class="border-b px-3 py-2 text-xs font-medium">{{ t("tableImport.mapping") }}</div>
<div class="max-h-[320px] overflow-auto p-2">
<div v-for="sourceColumn in preview.columns" :key="sourceColumn" class="grid grid-cols-[1fr_1fr] items-center gap-2 py-1">
<div class="truncate font-mono text-xs" :title="sourceColumn">
{{ sourceColumn }}
</div>
<Select :model-value="columnMapping[sourceColumn] || SKIP_VALUE" @update:model-value="(value: any) => updateMapping(sourceColumn, value)">
<SelectTrigger class="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="SKIP_VALUE">{{ t("tableImport.skipColumn") }}</SelectItem>
<SelectItem v-for="column in targetColumns" :key="column.name" :value="column.name">
{{ column.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div class="min-w-0 rounded-md border">
<div class="border-b px-3 py-2 text-xs font-medium">{{ t("tableImport.preview") }}</div>
<div class="max-h-[320px] overflow-auto">
<table class="min-w-full border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 bg-background">
<tr>
<th v-for="column in preview.columns" :key="column" class="border-b border-r px-2 py-1.5 text-left font-medium">
<span class="block max-w-[140px] truncate">{{ column }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in preview.rows" :key="rowIndex">
<td v-for="(cell, colIndex) in row" :key="colIndex" class="max-w-[180px] border-b border-r px-2 py-1.5 font-mono" :class="{ 'text-muted-foreground': cell === null }">
<span class="block truncate">{{ formatCell(cell) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="mappingValidation.errors.length" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{{ mappingValidation.errors.join("; ") }}
</div>
<div v-else-if="requiredUnmappedColumns.length" class="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ t("tableImport.requiredUnmapped", { columns: requiredUnmappedColumns.join(", ") }) }}</span>
</div>
</div>
<div v-else-if="wizardStep === 'review'" class="space-y-3">
<div class="grid grid-cols-2 gap-3 text-xs">
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.target") }}</div>
<div class="truncate font-medium">{{ targetLabel }}</div>
</div>
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.sourceFile") }}</div>
<div class="truncate font-medium">{{ preview?.fileName }}</div>
</div>
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.rows") }}</div>
<div class="font-medium">{{ preview?.totalRows.toLocaleString() }}</div>
</div>
<div class="rounded-md border px-3 py-2">
<div class="text-muted-foreground">{{ t("tableImport.mapped") }}</div>
<div class="font-medium">{{ mappedCount }} / {{ preview?.columns.length || 0 }}</div>
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1.5">
<Label class="text-xs">{{ t("tableImport.mode") }}</Label>
<Select :model-value="importMode" @update:model-value="(value: any) => (importMode = value)">
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append">{{ t("tableImport.append") }}</SelectItem>
<SelectItem value="truncate">{{ t("tableImport.truncate") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.batchSize") }}</Label>
<Input v-model.number="batchSize" type="number" min="1" class="h-8 text-xs" />
</div>
</div>
<div v-if="importMode === 'truncate'" class="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ t("tableImport.truncateWarning") }}</span>
</div>
</div>
<div v-else class="space-y-4">
<div class="rounded-md border px-4 py-5">
<div class="flex items-center gap-3">
<Loader2 v-if="running && !cancelling" class="h-5 w-5 animate-spin text-primary" />
<Square v-else-if="cancelling || progress?.status === 'cancelled'" class="h-5 w-5 fill-current text-destructive" />
<CheckCircle2 v-else-if="progress?.status === 'done'" class="h-5 w-5 text-emerald-600" />
<AlertTriangle v-else-if="progress?.status === 'error'" class="h-5 w-5 text-destructive" />
<FileUp v-else class="h-5 w-5 text-muted-foreground" />
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ t(`tableImport.status_${progress?.status || "idle"}`) }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ progress?.rowsImported ?? 0 }} / {{ progress?.totalRows ?? preview?.totalRows ?? 0 }} · {{ progressPercent }}%</div>
</div>
</div>
<div class="mt-4 h-2 overflow-hidden rounded bg-muted">
<div class="h-full bg-primary transition-all" :style="{ width: `${progressPercent}%` }" />
</div>
</div>
<div v-if="errorMessage || progress?.error" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{{ errorMessage || progress?.error }}
</div>
</div>
@ -344,7 +639,7 @@ watch(
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-if="errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<div v-if="errorMessage && wizardStep !== 'execution'" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{{ errorMessage }}
</div>
</div>
@ -352,16 +647,28 @@ watch(
<DialogFooter>
<Button variant="outline" :disabled="running" @click="open = false">
<X class="mr-1.5 h-3.5 w-3.5" />
{{ t("dangerDialog.cancel") }}
{{ terminalStatus ? t("common.close") : t("dangerDialog.cancel") }}
</Button>
<Button v-if="running" variant="destructive" :disabled="cancelling" @click="cancelImport">
<Button v-if="canGoBack" variant="outline" @click="goBack">
<ArrowLeft class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.back") }}
</Button>
<Button v-if="wizardStep === 'source' || wizardStep === 'options' || wizardStep === 'mapping'" :disabled="!canGoNext || loadingPreview" @click="goNext">
<ArrowRight class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.next") }}
</Button>
<Button v-else-if="wizardStep === 'review'" :disabled="!canImport" @click="startImport">
<Upload class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.start") }}
</Button>
<Button v-else-if="running" variant="destructive" :disabled="cancelling" @click="cancelImport">
<Loader2 v-if="cancelling" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Square v-else class="mr-1.5 h-3.5 w-3.5 fill-current" />
{{ t("sqlFile.cancel") }}
</Button>
<Button v-else :disabled="!canImport" @click="startImport">
<Upload class="mr-1.5 h-3.5 w-3.5" />
{{ t("tableImport.start") }}
<Button v-else-if="progress?.status === 'done'" @click="open = false">
<Check class="mr-1.5 h-3.5 w-3.5" />
{{ t("common.done") }}
</Button>
</DialogFooter>
</DialogScrollContent>

View File

@ -2187,6 +2187,39 @@ export default {
title: "Import Table Data",
target: "Target table",
selectFile: "Select File",
changeFile: "Change File",
noFileSelected: "No file selected",
sourceFile: "Source file",
sourceFormat: "Source format",
stepSource: "Source",
stepOptions: "Options",
stepMapping: "Mapping",
stepReview: "Review",
stepExecution: "Run",
formatCsv: "CSV",
formatCsvDescription: "Comma-separated text",
formatTsv: "TSV",
formatTsvDescription: "Tab-separated text",
formatDelimited: "Delimited",
formatDelimitedDescription: "Custom text delimiter",
formatJson: "JSON",
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
previewRows: "Preview rows",
delimiter: "Delimiter",
hasHeader: "First row is header",
trimValues: "Trim values",
emptyStringAsNull: "Empty string as NULL",
jsonShape: "JSON rows",
jsonShapeAuto: "Auto-detect",
jsonShapeObjects: "Objects",
jsonShapeArrays: "Arrays",
sheet: "Sheet",
firstSheet: "First sheet",
loadPreview: "Load Preview",
reloadPreview: "Reload Preview",
previewReady: "{rows} rows, {columns} columns",
file: "File",
rows: "Rows",
mapped: "Mapped",
@ -2199,6 +2232,15 @@ export default {
progress: "Progress",
start: "Start Import",
success: "Imported {count} rows",
back: "Back",
next: "Next",
truncateWarning: "Existing rows in the target table will be removed before import starts.",
requiredUnmapped: "Required target columns are not mapped: {columns}",
status_idle: "Ready to import",
status_running: "Importing data",
status_done: "Import complete",
status_error: "Import failed",
status_cancelled: "Import cancelled",
},
dataGenerate: {
title: "Data Generation",

View File

@ -2131,6 +2131,39 @@ export default withEnglishFallback({
title: "Importar datos a tabla",
target: "Tabla destino",
selectFile: "Seleccionar archivo",
changeFile: "Change File",
noFileSelected: "No file selected",
sourceFile: "Source file",
sourceFormat: "Source format",
stepSource: "Source",
stepOptions: "Options",
stepMapping: "Mapping",
stepReview: "Review",
stepExecution: "Run",
formatCsv: "CSV",
formatCsvDescription: "Comma-separated text",
formatTsv: "TSV",
formatTsvDescription: "Tab-separated text",
formatDelimited: "Delimited",
formatDelimitedDescription: "Custom text delimiter",
formatJson: "JSON",
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
previewRows: "Preview rows",
delimiter: "Delimiter",
hasHeader: "First row is header",
trimValues: "Trim values",
emptyStringAsNull: "Empty string as NULL",
jsonShape: "JSON rows",
jsonShapeAuto: "Auto-detect",
jsonShapeObjects: "Objects",
jsonShapeArrays: "Arrays",
sheet: "Sheet",
firstSheet: "First sheet",
loadPreview: "Load Preview",
reloadPreview: "Reload Preview",
previewReady: "{rows} rows, {columns} columns",
file: "Archivo",
rows: "Filas",
mapped: "Mapeadas",
@ -2143,6 +2176,15 @@ export default withEnglishFallback({
progress: "Progreso",
start: "Iniciar importación",
success: "Se importaron {count} filas",
back: "Back",
next: "Next",
truncateWarning: "Existing rows in the target table will be removed before import starts.",
requiredUnmapped: "Required target columns are not mapped: {columns}",
status_idle: "Ready to import",
status_running: "Importing data",
status_done: "Import complete",
status_error: "Import failed",
status_cancelled: "Import cancelled",
},
dataGenerate: {
title: "Generación de datos",

View File

@ -2129,6 +2129,39 @@ export default withEnglishFallback({
title: "Importa Dati Tabella",
target: "Tabella di destinazione",
selectFile: "Seleziona File",
changeFile: "Change File",
noFileSelected: "No file selected",
sourceFile: "Source file",
sourceFormat: "Source format",
stepSource: "Source",
stepOptions: "Options",
stepMapping: "Mapping",
stepReview: "Review",
stepExecution: "Run",
formatCsv: "CSV",
formatCsvDescription: "Comma-separated text",
formatTsv: "TSV",
formatTsvDescription: "Tab-separated text",
formatDelimited: "Delimited",
formatDelimitedDescription: "Custom text delimiter",
formatJson: "JSON",
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
previewRows: "Preview rows",
delimiter: "Delimiter",
hasHeader: "First row is header",
trimValues: "Trim values",
emptyStringAsNull: "Empty string as NULL",
jsonShape: "JSON rows",
jsonShapeAuto: "Auto-detect",
jsonShapeObjects: "Objects",
jsonShapeArrays: "Arrays",
sheet: "Sheet",
firstSheet: "First sheet",
loadPreview: "Load Preview",
reloadPreview: "Reload Preview",
previewReady: "{rows} rows, {columns} columns",
file: "File",
rows: "Righe",
mapped: "Mappata",
@ -2141,6 +2174,15 @@ export default withEnglishFallback({
progress: "Progresso",
start: "Avvia Importazione",
success: "Importate {count} righe",
back: "Back",
next: "Next",
truncateWarning: "Existing rows in the target table will be removed before import starts.",
requiredUnmapped: "Required target columns are not mapped: {columns}",
status_idle: "Ready to import",
status_running: "Importing data",
status_done: "Import complete",
status_error: "Import failed",
status_cancelled: "Import cancelled",
},
dataGenerate: {
title: "Generazione dati",

View File

@ -2129,6 +2129,39 @@ export default withEnglishFallback({
title: "テーブルデータをインポート",
target: "対象テーブル",
selectFile: "ファイルを選択",
changeFile: "Change File",
noFileSelected: "No file selected",
sourceFile: "Source file",
sourceFormat: "Source format",
stepSource: "Source",
stepOptions: "Options",
stepMapping: "Mapping",
stepReview: "Review",
stepExecution: "Run",
formatCsv: "CSV",
formatCsvDescription: "Comma-separated text",
formatTsv: "TSV",
formatTsvDescription: "Tab-separated text",
formatDelimited: "Delimited",
formatDelimitedDescription: "Custom text delimiter",
formatJson: "JSON",
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
previewRows: "Preview rows",
delimiter: "Delimiter",
hasHeader: "First row is header",
trimValues: "Trim values",
emptyStringAsNull: "Empty string as NULL",
jsonShape: "JSON rows",
jsonShapeAuto: "Auto-detect",
jsonShapeObjects: "Objects",
jsonShapeArrays: "Arrays",
sheet: "Sheet",
firstSheet: "First sheet",
loadPreview: "Load Preview",
reloadPreview: "Reload Preview",
previewReady: "{rows} rows, {columns} columns",
file: "ファイル",
rows: "行数",
mapped: "マッピング済み",
@ -2141,6 +2174,15 @@ export default withEnglishFallback({
progress: "進捗",
start: "インポート開始",
success: "{count}行をインポートしました",
back: "Back",
next: "Next",
truncateWarning: "Existing rows in the target table will be removed before import starts.",
requiredUnmapped: "Required target columns are not mapped: {columns}",
status_idle: "Ready to import",
status_running: "Importing data",
status_done: "Import complete",
status_error: "Import failed",
status_cancelled: "Import cancelled",
},
dataGenerate: {
title: "データ生成",

View File

@ -2130,6 +2130,39 @@ export default withEnglishFallback({
title: "Importar Dados da Tabela",
target: "Tabela de destino",
selectFile: "Selecionar arquivo",
changeFile: "Change File",
noFileSelected: "No file selected",
sourceFile: "Source file",
sourceFormat: "Source format",
stepSource: "Source",
stepOptions: "Options",
stepMapping: "Mapping",
stepReview: "Review",
stepExecution: "Run",
formatCsv: "CSV",
formatCsvDescription: "Comma-separated text",
formatTsv: "TSV",
formatTsvDescription: "Tab-separated text",
formatDelimited: "Delimited",
formatDelimitedDescription: "Custom text delimiter",
formatJson: "JSON",
formatJsonDescription: "Object or array rows",
formatExcel: "Excel",
formatExcelDescription: "XLS, XLSX, XLSM",
previewRows: "Preview rows",
delimiter: "Delimiter",
hasHeader: "First row is header",
trimValues: "Trim values",
emptyStringAsNull: "Empty string as NULL",
jsonShape: "JSON rows",
jsonShapeAuto: "Auto-detect",
jsonShapeObjects: "Objects",
jsonShapeArrays: "Arrays",
sheet: "Sheet",
firstSheet: "First sheet",
loadPreview: "Load Preview",
reloadPreview: "Reload Preview",
previewReady: "{rows} rows, {columns} columns",
file: "Arquivo",
rows: "Linhas",
mapped: "Mapeado",
@ -2142,6 +2175,15 @@ export default withEnglishFallback({
progress: "Progresso",
start: "Iniciar importação",
success: "{count} linhas importadas",
back: "Back",
next: "Next",
truncateWarning: "Existing rows in the target table will be removed before import starts.",
requiredUnmapped: "Required target columns are not mapped: {columns}",
status_idle: "Ready to import",
status_running: "Importing data",
status_done: "Import complete",
status_error: "Import failed",
status_cancelled: "Import cancelled",
},
dataGenerate: {
title: "Geração de dados",

View File

@ -2187,6 +2187,39 @@ export default withEnglishFallback({
title: "导入表数据",
target: "目标表",
selectFile: "选择文件",
changeFile: "更换文件",
noFileSelected: "未选择文件",
sourceFile: "源文件",
sourceFormat: "源格式",
stepSource: "来源",
stepOptions: "选项",
stepMapping: "映射",
stepReview: "确认",
stepExecution: "执行",
formatCsv: "CSV",
formatCsvDescription: "逗号分隔文本",
formatTsv: "TSV",
formatTsvDescription: "制表符分隔文本",
formatDelimited: "分隔文本",
formatDelimitedDescription: "自定义文本分隔符",
formatJson: "JSON",
formatJsonDescription: "对象行或数组行",
formatExcel: "Excel",
formatExcelDescription: "XLS、XLSX、XLSM",
previewRows: "预览行数",
delimiter: "分隔符",
hasHeader: "首行为表头",
trimValues: "裁剪空白",
emptyStringAsNull: "空字符串作为 NULL",
jsonShape: "JSON 行结构",
jsonShapeAuto: "自动检测",
jsonShapeObjects: "对象",
jsonShapeArrays: "数组",
sheet: "工作表",
firstSheet: "第一个工作表",
loadPreview: "加载预览",
reloadPreview: "重新加载预览",
previewReady: "{rows} 行,{columns} 列",
file: "文件",
rows: "行数",
mapped: "已映射",
@ -2199,6 +2232,15 @@ export default withEnglishFallback({
progress: "进度",
start: "开始导入",
success: "已导入 {count} 行",
back: "上一步",
next: "下一步",
truncateWarning: "导入开始前会先删除目标表中的现有数据。",
requiredUnmapped: "以下必填目标列未映射:{columns}",
status_idle: "准备导入",
status_running: "正在导入数据",
status_done: "导入完成",
status_error: "导入失败",
status_cancelled: "导入已取消",
},
dataGenerate: {
title: "数据生成",

View File

@ -2033,6 +2033,39 @@ export default withEnglishFallback({
title: "匯入資料表資料",
target: "目標資料表",
selectFile: "選擇檔案",
changeFile: "更換檔案",
noFileSelected: "未選擇檔案",
sourceFile: "來源檔案",
sourceFormat: "來源格式",
stepSource: "來源",
stepOptions: "選項",
stepMapping: "映射",
stepReview: "確認",
stepExecution: "執行",
formatCsv: "CSV",
formatCsvDescription: "逗號分隔文字",
formatTsv: "TSV",
formatTsvDescription: "定位字元分隔文字",
formatDelimited: "分隔文字",
formatDelimitedDescription: "自訂文字分隔符",
formatJson: "JSON",
formatJsonDescription: "物件列或陣列列",
formatExcel: "Excel",
formatExcelDescription: "XLS、XLSX、XLSM",
previewRows: "預覽列數",
delimiter: "分隔符",
hasHeader: "首列為標頭",
trimValues: "裁剪空白",
emptyStringAsNull: "空字串作為 NULL",
jsonShape: "JSON 列結構",
jsonShapeAuto: "自動偵測",
jsonShapeObjects: "物件",
jsonShapeArrays: "陣列",
sheet: "工作表",
firstSheet: "第一個工作表",
loadPreview: "載入預覽",
reloadPreview: "重新載入預覽",
previewReady: "{rows} 列,{columns} 欄",
file: "檔案",
rows: "列數",
mapped: "已映射",
@ -2045,6 +2078,15 @@ export default withEnglishFallback({
progress: "進度",
start: "開始匯入",
success: "已匯入 {count} 行",
back: "上一步",
next: "下一步",
truncateWarning: "匯入開始前會先刪除目標資料表中的現有資料。",
requiredUnmapped: "以下必填目標欄位未映射:{columns}",
status_idle: "準備匯入",
status_running: "正在匯入資料",
status_done: "匯入完成",
status_error: "匯入失敗",
status_cancelled: "匯入已取消",
},
dataGenerate: {
title: "資料生成",

View File

@ -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");
});
});

View File

@ -501,7 +501,11 @@ export type {
TransferTableNameCase,
TableImportMode,
TableImportStatus,
TableImportSourceFormat,
TableImportJsonShape,
TableImportColumnMapping,
TableImportParseOptions,
TableImportPreviewRequest,
TableImportPreview,
TableImportRequest,
TableImportSummary,

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();

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> {

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)];
}

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,14 @@
use std::path::{Path as StdPath, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::body::Bytes;
use axum::extract::{Multipart, Path, State};
use axum::response::sse::{Event, Sse};
use axum::Json;
use dbx_core::table_import::{self, TableImportRequest};
use dbx_core::table_import::{
self, TableImportParseOptions, TableImportPreviewRequest, TableImportRequest, TableImportSourceFormat,
};
use dbx_core::transfer;
use futures::stream::Stream;
use serde::Deserialize;
@ -27,23 +32,59 @@ pub async fn preview_import(
State(state): State<Arc<WebState>>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, AppError> {
let tmp_dir = state.data_dir.join("tmp");
let tmp_dir = import_upload_dir(&state.data_dir);
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?;
cleanup_expired_import_uploads(&tmp_dir, Duration::from_secs(24 * 60 * 60));
if let Some(field) = multipart.next_field().await.map_err(|e| AppError(e.to_string()))? {
let file_name = field.file_name().unwrap_or("upload.csv").to_string();
let data = field.bytes().await.map_err(|e| AppError(e.to_string()))?;
let mut uploaded_file: Option<(String, Bytes)> = None;
let mut source_format: Option<TableImportSourceFormat> = None;
let mut parse_options = TableImportParseOptions::default();
let mut preview_limit: Option<usize> = None;
while let Some(field) = multipart.next_field().await.map_err(|e| AppError(e.to_string()))? {
let name = field.name().unwrap_or_default().to_string();
if name == "file" {
let file_name = field.file_name().unwrap_or("upload.csv").to_string();
let data = field.bytes().await.map_err(|e| AppError(e.to_string()))?;
uploaded_file = Some((file_name, data));
} else {
let value = field.text().await.map_err(|e| AppError(e.to_string()))?;
match name.as_str() {
"sourceFormat" => {
source_format = Some(
serde_json::from_value(serde_json::Value::String(value))
.map_err(|e| AppError(e.to_string()))?,
);
}
"parseOptions" => {
parse_options = serde_json::from_str(&value).map_err(|e| AppError(e.to_string()))?;
}
"previewLimit" => {
preview_limit = value.parse::<usize>().ok();
}
_ => {}
}
}
}
if let Some((file_name, data)) = uploaded_file {
if data.len() > 100 * 1024 * 1024 {
return Err(AppError(format!("File too large: {} bytes (max {} bytes)", data.len(), 100 * 1024 * 1024)));
}
let file_path = tmp_dir.join(&file_name);
let source_ref = uuid::Uuid::new_v4().to_string();
let file_path = safe_uploaded_import_path(&tmp_dir, &file_name, &source_ref)?;
std::fs::write(&file_path, &data).map_err(|e| AppError(e.to_string()))?;
let file_path_str = file_path.to_string_lossy().to_string();
let preview = table_import::preview_table_import_file_core(&file_path_str).await;
let _ = tokio::fs::remove_file(&file_path).await;
let preview = table_import::preview_table_import_file_with_request(TableImportPreviewRequest {
file_path: file_path_str,
source_ref: Some(source_ref),
source_format,
parse_options,
preview_limit,
})
.await;
let preview = preview.map_err(AppError)?;
return Ok(Json(serde_json::to_value(preview).map_err(|e| AppError(e.to_string()))?));
}
@ -55,10 +96,13 @@ pub async fn execute_import(
State(state): State<Arc<WebState>>,
Json(body): Json<ExecuteImportWrapper>,
) -> Result<Json<serde_json::Value>, AppError> {
let req = body.request;
let mut req = body.request;
let file_path = validated_uploaded_import_path(&state.data_dir, &req.file_path)?;
req.file_path = file_path.to_string_lossy().to_string();
// Reject import early if the connection is read-only
if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await {
cleanup_uploaded_import_source(&req.file_path).await;
return Err(AppError(format!(
"Read-only mode: connection '{}' has read-only protection enabled. Import blocked.",
name
@ -79,12 +123,16 @@ pub async fn execute_import(
Err(e) => {
let _ = tx.send(
serde_json::json!({
"importId": req.import_id,
"importId": req.import_id.clone(),
"status": "error",
"rowsImported": 0,
"totalRows": 0,
"error": e
})
.to_string(),
);
cleanup_uploaded_import_source(&req.file_path).await;
state_clone.sse_channels.write().await.remove(&req.import_id);
return;
}
};
@ -94,12 +142,16 @@ pub async fn execute_import(
Err(e) => {
let _ = tx.send(
serde_json::json!({
"importId": req.import_id,
"importId": req.import_id.clone(),
"status": "error",
"rowsImported": 0,
"totalRows": 0,
"error": e
})
.to_string(),
);
cleanup_uploaded_import_source(&req.file_path).await;
state_clone.sse_channels.write().await.remove(&req.import_id);
return;
}
};
@ -134,6 +186,8 @@ pub async fn execute_import(
serde_json::json!({
"importId": import_id_for_cancel,
"status": "error",
"rowsImported": 0,
"totalRows": 0,
"error": e
})
.to_string(),
@ -141,6 +195,7 @@ pub async fn execute_import(
}
}
cleanup_uploaded_import_source(&req.file_path).await;
state_clone.sse_channels.write().await.remove(&req.import_id);
});
@ -165,3 +220,52 @@ pub async fn cancel_import(
transfer::set_cancelled(&req.import_id).await;
Json(serde_json::json!({ "cancelled": true }))
}
fn import_upload_dir(data_dir: &StdPath) -> PathBuf {
data_dir.join("tmp").join("table_import")
}
fn safe_uploaded_import_path(tmp_dir: &StdPath, file_name: &str, source_ref: &str) -> Result<PathBuf, AppError> {
let base_name = file_name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or("upload.csv").trim();
if base_name.is_empty() || base_name == "." || base_name == ".." {
return Err(AppError("Invalid import file name".to_string()));
}
Ok(tmp_dir.join(format!("{source_ref}-{base_name}")))
}
fn validated_uploaded_import_path(data_dir: &StdPath, file_path: &str) -> Result<PathBuf, AppError> {
let path = PathBuf::from(file_path);
if !path.is_absolute() {
return Err(AppError("Import source path must be absolute".to_string()));
}
let tmp_dir = import_upload_dir(data_dir).canonicalize().map_err(|e| AppError(e.to_string()))?;
let canonical_path =
path.canonicalize().map_err(|e| AppError(format!("Import source is no longer available: {e}")))?;
if !canonical_path.starts_with(&tmp_dir) {
return Err(AppError("Import source must be inside the uploaded import directory".to_string()));
}
Ok(canonical_path)
}
fn cleanup_expired_import_uploads(tmp_dir: &StdPath, max_age: Duration) {
let Ok(entries) = std::fs::read_dir(tmp_dir) else {
return;
};
let now = SystemTime::now();
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else {
continue;
};
let Ok(modified) = metadata.modified() else {
continue;
};
if now.duration_since(modified).map(|age| age > max_age).unwrap_or(false) {
let _ = std::fs::remove_file(entry.path());
}
}
}
async fn cleanup_uploaded_import_source(file_path: &str) {
let _ = tokio::fs::remove_file(file_path).await;
}

View File

@ -8,7 +8,9 @@ use crate::commands::connection::{ensure_connection_writable, AppState};
use crate::commands::transfer::get_db_type;
// Re-export types for backward compatibility
pub use dbx_core::table_import::{TableImportPreview, TableImportProgress, TableImportRequest, TableImportSummary};
pub use dbx_core::table_import::{
TableImportPreview, TableImportPreviewRequest, TableImportProgress, TableImportRequest, TableImportSummary,
};
static CANCELLED_IMPORTS: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
@ -29,8 +31,8 @@ async fn clear_cancelled(import_id: &str) {
}
#[tauri::command]
pub async fn preview_table_import_file(file_path: String) -> Result<TableImportPreview, String> {
dbx_core::table_import::preview_table_import_file_core(&file_path).await
pub async fn preview_table_import_file(request: TableImportPreviewRequest) -> Result<TableImportPreview, String> {
dbx_core::table_import::preview_table_import_file_with_request(request).await
}
#[tauri::command]