feat(import): add table import wizard
This commit is contained in:
parent
4b6a588354
commit
4666f8a5f2
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "データ生成",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "数据生成",
|
||||
|
|
|
|||
|
|
@ -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: "資料生成",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -501,7 +501,11 @@ export type {
|
|||
TransferTableNameCase,
|
||||
TableImportMode,
|
||||
TableImportStatus,
|
||||
TableImportSourceFormat,
|
||||
TableImportJsonShape,
|
||||
TableImportColumnMapping,
|
||||
TableImportParseOptions,
|
||||
TableImportPreviewRequest,
|
||||
TableImportPreview,
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue