feat(mongodb): add Compass-style collection import/export
This commit is contained in:
1 parent
bf4874906c
commit
8ff56b4837
47 files changed
+5626
-52
No files matched your search
@@ -271,6 +271,7 @@ async function initializeUpdatePreparation() {
|
||||
dialogs.showTransferDialog,
|
||||
dialogs.showSqlFileDialog,
|
||||
dialogs.showTableImportDialog,
|
||||
dialogs.showMongoImportDialog,
|
||||
dialogs.showTableDataGenerateDialog,
|
||||
dialogs.showDatabaseExportDialog,
|
||||
dialogs.showSchemaDiffDialog,
|
||||
|
||||
@@ -96,6 +96,7 @@ import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/st
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
|
||||
import type { EditNode } from "@/types/editor";
|
||||
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
@@ -2227,6 +2228,15 @@ function focusSearch(): boolean {
|
||||
return documentJsonEditorRef.value?.openSearch() ?? false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => connectionStore.mongoImportCompleted,
|
||||
(completed) => {
|
||||
if (!completed) return;
|
||||
if (completed.connectionId !== props.connectionId || completed.database !== props.database || completed.collection !== props.collection) return;
|
||||
void refreshDocuments();
|
||||
},
|
||||
);
|
||||
|
||||
watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
|
||||
if (mode === "document" && !editing && index !== null) return;
|
||||
documentViewerSearchActive.value = false;
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
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 { AlertTriangle, ArrowLeft, ArrowRight, FileUp, Loader2, Square, Upload, X } from "@lucide/vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { TABLE_IMPORT_ENCODING_OPTIONS } from "@/lib/table/tableImport";
|
||||
import { importPreviewInput, importSourceDisplayName, importTextDelimiterForName, uploadedImportSourceFromPreview, type UploadedImportSource } from "@/lib/import/importSource";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ completed: [] }>();
|
||||
|
||||
type WizardStep = "source" | "confirm" | "execution";
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const selectedSource = ref<string | File | null>(null);
|
||||
const sourceName = ref("");
|
||||
const format = ref<api.MongoImportFormat>("csv");
|
||||
const encoding = ref<api.TableImportTextEncoding>("auto");
|
||||
const delimiter = ref(",");
|
||||
const hasHeader = ref(true);
|
||||
const trim = ref(false);
|
||||
const emptyAsNull = ref(true);
|
||||
const typeMode = ref<api.MongoImportTypeMode>("auto");
|
||||
const recognizeObjectIdHex = ref(false);
|
||||
const skipErrorRows = ref(false);
|
||||
const batchSize = ref(500);
|
||||
const previewLimit = ref(50);
|
||||
const preview = ref<api.MongoImportPreview | null>(null);
|
||||
const loadingPreview = ref(false);
|
||||
const previewError = ref("");
|
||||
const wizardStep = ref<WizardStep>("source");
|
||||
const running = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const closeBlocked = ref(false);
|
||||
const importId = ref("");
|
||||
const progress = ref<api.MongoImportProgress | null>(null);
|
||||
const errorMessage = ref("");
|
||||
let previewRequestId = 0;
|
||||
let previewReloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let uploadedSource: UploadedImportSource | null = null;
|
||||
|
||||
const connection = computed(() => store.getConfig(props.connectionId));
|
||||
const effectivelyReadOnly = computed(() => connectionIsEffectivelyReadOnly(connection.value));
|
||||
const fileLabel = computed(() => sourceName.value || t("tableImport.noFileSelected"));
|
||||
const encodingOptions = TABLE_IMPORT_ENCODING_OPTIONS;
|
||||
const targetLabel = computed(() => `${props.database}.${props.collection}`);
|
||||
|
||||
const parseOptions = computed(
|
||||
(): api.MongoImportParseOptions => ({
|
||||
encoding: encoding.value,
|
||||
delimiter: delimiter.value,
|
||||
hasHeader: hasHeader.value,
|
||||
trim: trim.value,
|
||||
emptyAsNull: emptyAsNull.value,
|
||||
typeMode: typeMode.value,
|
||||
recognizeObjectIdHex: recognizeObjectIdHex.value,
|
||||
skipErrorRows: skipErrorRows.value,
|
||||
}),
|
||||
);
|
||||
|
||||
function formatFromName(name: string): api.MongoImportFormat {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.endsWith(".ndjson") || lower.endsWith(".jsonl")) return "ndjson";
|
||||
if (lower.endsWith(".json")) return "json";
|
||||
return "csv";
|
||||
}
|
||||
|
||||
function releasePreviewSource() {
|
||||
const sourceRef = uploadedSource?.sourceRef;
|
||||
uploadedSource = null;
|
||||
preview.value = null;
|
||||
if (sourceRef) void api.releaseMongodbImportSource(sourceRef);
|
||||
}
|
||||
|
||||
function assignSource(source: string | File) {
|
||||
releasePreviewSource();
|
||||
selectedSource.value = source;
|
||||
sourceName.value = importSourceDisplayName(source);
|
||||
format.value = formatFromName(sourceName.value);
|
||||
typeMode.value = format.value === "csv" ? "auto" : "extendedJson";
|
||||
delimiter.value = importTextDelimiterForName(sourceName.value);
|
||||
wizardStep.value = "source";
|
||||
queuePreview();
|
||||
}
|
||||
|
||||
async function selectFile() {
|
||||
if (!isTauriRuntime()) {
|
||||
fileInput.value?.click();
|
||||
return;
|
||||
}
|
||||
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: t("mongo.import.fileFilter"), extensions: ["csv", "json", "ndjson", "jsonl", "tsv"] }],
|
||||
});
|
||||
if (!selected || Array.isArray(selected)) return;
|
||||
assignSource(selected);
|
||||
}
|
||||
|
||||
function handleFileInputChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (file) assignSource(file);
|
||||
}
|
||||
|
||||
function queuePreview() {
|
||||
if (!selectedSource.value) return;
|
||||
if (previewReloadTimer) clearTimeout(previewReloadTimer);
|
||||
previewReloadTimer = setTimeout(() => {
|
||||
void loadPreview();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
const source = selectedSource.value;
|
||||
if (!source) return;
|
||||
const requestId = ++previewRequestId;
|
||||
loadingPreview.value = true;
|
||||
previewError.value = "";
|
||||
try {
|
||||
const input = importPreviewInput(uploadedSource, source);
|
||||
const next = await api.previewMongodbImportFile(input.fileOrPath, {
|
||||
format: format.value,
|
||||
parseOptions: parseOptions.value,
|
||||
previewLimit: previewLimit.value,
|
||||
sourceRef: input.sourceRef,
|
||||
});
|
||||
if (requestId !== previewRequestId) return;
|
||||
preview.value = next;
|
||||
uploadedSource = uploadedImportSourceFromPreview(next) ?? uploadedSource;
|
||||
} catch (error) {
|
||||
if (requestId !== previewRequestId) return;
|
||||
previewError.value = error instanceof Error ? error.message : String(error);
|
||||
if (!uploadedSource) preview.value = null;
|
||||
} finally {
|
||||
if (requestId === previewRequestId) loadingPreview.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([format, encoding, delimiter, hasHeader, trim, emptyAsNull, typeMode, recognizeObjectIdHex, previewLimit], () => {
|
||||
if (selectedSource.value) queuePreview();
|
||||
});
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value) return;
|
||||
if (running.value) {
|
||||
closeBlocked.value = true;
|
||||
open.value = true;
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
});
|
||||
|
||||
function reset() {
|
||||
releasePreviewSource();
|
||||
selectedSource.value = null;
|
||||
sourceName.value = "";
|
||||
previewError.value = "";
|
||||
wizardStep.value = "source";
|
||||
running.value = false;
|
||||
cancelling.value = false;
|
||||
closeBlocked.value = false;
|
||||
progress.value = null;
|
||||
errorMessage.value = "";
|
||||
importId.value = "";
|
||||
}
|
||||
|
||||
function canConfirm() {
|
||||
return !!preview.value && !loadingPreview.value && !previewError.value && !effectivelyReadOnly.value && (!preview.value.errors.length || skipErrorRows.value);
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
const source = selectedSource.value;
|
||||
const currentPreview = preview.value;
|
||||
if (!source || !currentPreview) return;
|
||||
const reviewText = `db.${props.collection}.insertMany(/* ${currentPreview.estimatedRows ?? "?"} documents from ${sourceName.value} */)`;
|
||||
const started = await executeWithProductionContextGuard({
|
||||
connection: connection.value,
|
||||
database: props.database,
|
||||
reviewText,
|
||||
source: t("readOnlyUnlock.sourceMongoImport"),
|
||||
execute: async () => true,
|
||||
});
|
||||
if (!started) return;
|
||||
running.value = true;
|
||||
wizardStep.value = "execution";
|
||||
errorMessage.value = "";
|
||||
importId.value = uuid();
|
||||
try {
|
||||
const filePath = typeof source === "string" ? source : currentPreview.filePath;
|
||||
const size = Math.min(5000, Math.max(100, Math.trunc(Number(batchSize.value)) || 500));
|
||||
batchSize.value = size;
|
||||
await api.importMongodbFile(
|
||||
{
|
||||
importId: importId.value,
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
collection: props.collection,
|
||||
filePath,
|
||||
sourceRef: currentPreview.sourceRef,
|
||||
format: format.value,
|
||||
parseOptions: parseOptions.value,
|
||||
batchSize: size,
|
||||
},
|
||||
(next) => {
|
||||
progress.value = next;
|
||||
},
|
||||
);
|
||||
if (currentPreview.sourceRef) {
|
||||
await api.releaseMongodbImportSource(currentPreview.sourceRef);
|
||||
if (uploadedSource?.sourceRef === currentPreview.sourceRef) uploadedSource = null;
|
||||
if (preview.value?.sourceRef === currentPreview.sourceRef) preview.value = { ...preview.value, sourceRef: null };
|
||||
}
|
||||
toast(t("mongo.import.success", { count: progress.value?.rowsInserted ?? 0 }));
|
||||
store.mongoImportCompleted = {
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
collection: props.collection,
|
||||
at: Date.now(),
|
||||
};
|
||||
emit("completed");
|
||||
running.value = false;
|
||||
open.value = false;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||
running.value = false;
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelImport() {
|
||||
if (!importId.value) return;
|
||||
cancelling.value = true;
|
||||
await api.cancelMongodbImport(importId.value);
|
||||
}
|
||||
|
||||
function formatPreviewCell(value: unknown) {
|
||||
if (value == null) return "null";
|
||||
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
function setFormat(value: unknown) {
|
||||
if (value === "csv" || value === "json" || value === "ndjson") format.value = value;
|
||||
}
|
||||
|
||||
function setEncoding(value: unknown) {
|
||||
if (value === "auto" || value === "utf8" || value === "gbk" || value === "utf16Le" || value === "utf16Be") encoding.value = value;
|
||||
}
|
||||
|
||||
function setTypeMode(value: unknown) {
|
||||
if (value === "string" || value === "auto" || value === "extendedJson") typeMode.value = value;
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (running.value) {
|
||||
closeBlocked.value = true;
|
||||
return;
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
@update:open="
|
||||
(value) => {
|
||||
if (!value) requestClose();
|
||||
else open = true;
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogScrollContent class="flex max-h-[calc(var(--dbx-viewport-height)-6rem)] min-h-0 flex-col overflow-hidden sm:max-w-[980px]" aria-labelledby="mongo-import-title">
|
||||
<DialogHeader class="shrink-0 pr-8">
|
||||
<DialogTitle id="mongo-import-title" class="flex items-center gap-2 text-base">
|
||||
<FileUp class="h-4 w-4" />
|
||||
{{ t("mongo.import.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto py-2 pr-1 text-sm">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||
<input ref="fileInput" type="file" class="hidden" accept=".csv,.json,.ndjson,.jsonl,.tsv" @change="handleFileInputChange" />
|
||||
<div class="flex h-10 min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-3">
|
||||
<span class="shrink-0 text-xs text-muted-foreground">{{ t("mongo.import.target") }}</span>
|
||||
<span class="min-w-0 truncate text-sm font-medium">{{ targetLabel }}</span>
|
||||
</div>
|
||||
<Button variant="outline" class="h-10 px-3" :disabled="running || loadingPreview || wizardStep === 'execution'" @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" />
|
||||
{{ selectedSource ? t("tableImport.changeFile") : t("tableImport.selectFile") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="effectivelyReadOnly" class="text-xs text-amber-600">{{ t("mongo.import.readonly") }}</div>
|
||||
|
||||
<div v-if="wizardStep === 'source'" class="space-y-3">
|
||||
<div class="grid grid-cols-3 gap-3 rounded-md border p-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.sourceFormat") }}</Label>
|
||||
<Select :model-value="format" @update:model-value="setFormat">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="ndjson">NDJSON</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.encoding") }}</Label>
|
||||
<Select :model-value="encoding" @update:model-value="setEncoding">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in encodingOptions" :key="option.value" :value="option.value">{{ t(option.labelKey) }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="format === 'csv'" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("tableImport.delimiter") }}</Label>
|
||||
<Input v-model="delimiter" class="h-8 text-xs font-mono" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("mongo.import.typeMode") }}</Label>
|
||||
<Select :model-value="typeMode" @update:model-value="setTypeMode">
|
||||
<SelectTrigger class="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{{ t("mongo.import.typeString") }}</SelectItem>
|
||||
<SelectItem value="auto">{{ t("mongo.import.typeAuto") }}</SelectItem>
|
||||
<SelectItem value="extendedJson">{{ t("mongo.import.typeExtendedJson") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("mongo.import.batchSize") }}</Label>
|
||||
<Input v-model.number="batchSize" type="number" min="100" max="5000" 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">{{ fileLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border p-3">
|
||||
<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="trim" 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="emptyAsNull" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.emptyAsNull") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="recognizeObjectIdHex" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.recognizeObjectIdHex") }}</label>
|
||||
<label class="flex items-center gap-2 text-xs"><input v-model="skipErrorRows" type="checkbox" class="h-3.5 w-3.5 accent-primary" /> {{ t("mongo.import.skipErrorRows") }}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPreview" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t("mongo.import.previewing") }}
|
||||
</div>
|
||||
<div v-else-if="previewError" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ previewError }}</div>
|
||||
<div v-else-if="preview" class="space-y-2">
|
||||
<div class="text-xs text-muted-foreground">{{ t("mongo.import.estimatedRows", { count: preview.estimatedRows ?? 0 }) }}</div>
|
||||
<div class="max-h-56 overflow-auto rounded-md border">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-b px-2 py-1 text-left">#</th>
|
||||
<th v-for="column in preview.columns" :key="column.name" class="border-b px-2 py-1 text-left">
|
||||
{{ column.name }}
|
||||
<span class="text-muted-foreground">({{ column.inferredType }})</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in preview.rows" :key="index">
|
||||
<td class="border-b px-2 py-1">{{ preview.rowNumbers?.[index] ?? index + 1 }}</td>
|
||||
<td v-for="column in preview.columns" :key="column.name" class="border-b px-2 py-1 font-mono">
|
||||
{{ formatPreviewCell(row[column.name]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="preview.errors.length" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<div v-for="(issue, index) in preview.errors" :key="index">{{ issue.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="wizardStep === 'confirm'" class="space-y-2 text-sm">
|
||||
<div>{{ t("mongo.import.confirmAppend") }}</div>
|
||||
<div>{{ t("mongo.import.batchSize") }}: {{ batchSize }}</div>
|
||||
<div>{{ t("mongo.import.duplicateIdPolicy") }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="wizardStep === 'execution'" class="space-y-2 text-sm">
|
||||
<div>{{ t("mongo.import.phase." + (progress?.phase ?? "preparing")) }}</div>
|
||||
<div>{{ t("mongo.import.rowsRead") }}: {{ progress?.rowsRead ?? 0 }}</div>
|
||||
<div>{{ t("mongo.import.rowsInserted") }}: {{ progress?.rowsInserted ?? 0 }}</div>
|
||||
<div>{{ t("mongo.import.rowsFailed") }}: {{ progress?.rowsFailed ?? 0 }}</div>
|
||||
<div>{{ t("mongo.import.batchesCommitted") }}: {{ progress?.batchesCommitted ?? 0 }}</div>
|
||||
<div v-if="errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ errorMessage }}</div>
|
||||
<div v-if="closeBlocked" class="flex items-center gap-2 text-amber-600">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
{{ t("mongo.import.stillRunning") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter class="shrink-0">
|
||||
<Button v-if="wizardStep === 'execution' && 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 variant="outline" @click="requestClose">
|
||||
<X class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
<Button v-if="wizardStep === 'confirm'" variant="outline" @click="wizardStep = 'source'">
|
||||
<ArrowLeft class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("tableImport.back") }}
|
||||
</Button>
|
||||
<Button v-if="wizardStep === 'source'" :disabled="!canConfirm()" @click="wizardStep = 'confirm'">
|
||||
<ArrowRight class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("tableImport.next") }}
|
||||
</Button>
|
||||
<Button v-if="wizardStep === 'confirm'" :disabled="effectivelyReadOnly" @click="startImport">
|
||||
<Upload class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("tableImport.start") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogScrollContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { defineComponent, h, nextTick, ref } from "vue";
|
||||
import { createApp } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MongoImportPreview } from "@/lib/backend/api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
previewMongodbImportFile: vi.fn(),
|
||||
importMongodbFile: vi.fn(),
|
||||
cancelMongodbImport: vi.fn(),
|
||||
releaseMongodbImportSource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => api);
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ getConfig: () => ({ id: "c1", name: "mongo", read_only: false }), mongoImportCompleted: null }),
|
||||
}));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/lib/database/productionExecutionGuard", () => ({
|
||||
executeWithProductionContextGuard: async ({ execute }: { execute: () => Promise<unknown> }) => execute(),
|
||||
}));
|
||||
|
||||
import MongoImportDialog from "../MongoImportDialog.vue";
|
||||
|
||||
function previewResult(overrides: Partial<MongoImportPreview> = {}): MongoImportPreview {
|
||||
return {
|
||||
sourceRef: "src-1",
|
||||
format: "csv",
|
||||
fileName: "orders.csv",
|
||||
filePath: "/tmp/orders.csv",
|
||||
sizeBytes: 12,
|
||||
columns: [{ name: "id", inferredType: "string" }],
|
||||
rows: [{ id: "1" }],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
estimatedRows: 1,
|
||||
estimatedRowsExact: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MongoImportDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
api.previewMongodbImportFile.mockReset().mockResolvedValue(previewResult());
|
||||
api.releaseMongodbImportSource.mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("keeps the collection target in the wizard", async () => {
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(MongoImportDialog, {
|
||||
open: true,
|
||||
connectionId: "c1",
|
||||
database: "shop",
|
||||
collection: "orders",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(document.body);
|
||||
await nextTick();
|
||||
expect(document.body.textContent).toContain("shop.orders");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("reuses the uploaded sourceRef on later previews and releases it on close", async () => {
|
||||
const open = ref(true);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(MongoImportDialog, {
|
||||
open: open.value,
|
||||
"onUpdate:open": (value: boolean) => {
|
||||
open.value = value;
|
||||
},
|
||||
connectionId: "c1",
|
||||
database: "shop",
|
||||
collection: "orders",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(document.body);
|
||||
await nextTick();
|
||||
|
||||
const input = document.querySelector("input[type='file']") as HTMLInputElement;
|
||||
const file = new File(["id\n1"], "orders.csv", { type: "text/csv" });
|
||||
Object.defineProperty(input, "files", { value: [file] });
|
||||
input.dispatchEvent(new Event("change"));
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
|
||||
expect(api.previewMongodbImportFile).toHaveBeenCalledTimes(1);
|
||||
const firstSource = api.previewMongodbImportFile.mock.calls[0]?.[0];
|
||||
expect(firstSource).toBeInstanceOf(File);
|
||||
expect((firstSource as File).name).toBe("orders.csv");
|
||||
expect(api.previewMongodbImportFile.mock.calls[0]?.[1]).toMatchObject({ sourceRef: null });
|
||||
|
||||
const header = document.querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
header.click();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
|
||||
expect(api.previewMongodbImportFile).toHaveBeenCalledTimes(2);
|
||||
expect(api.previewMongodbImportFile.mock.calls[1]?.[0]).toBe("/tmp/orders.csv");
|
||||
expect(api.previewMongodbImportFile.mock.calls[1]?.[1]).toMatchObject({ sourceRef: "src-1" });
|
||||
|
||||
open.value = false;
|
||||
await nextTick();
|
||||
expect(api.releaseMongodbImportSource).toHaveBeenCalledWith("src-1");
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
requiredImportTargetColumns,
|
||||
resolveTableImportElapsed,
|
||||
suggestImportTargetDataTypes,
|
||||
TABLE_IMPORT_ENCODING_OPTIONS,
|
||||
tableImportProgressPercent,
|
||||
validateImportMappings,
|
||||
type TableImportWizardStep,
|
||||
} from "@/lib/table/tableImport";
|
||||
import { importPreviewInput, importSourceDisplayName, uploadedImportSourceFromPreview } from "@/lib/import/importSource";
|
||||
import { getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
|
||||
import { metadataSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import type { ColumnInfo } from "@/types/database";
|
||||
@@ -139,13 +141,7 @@ const formatOptions: Array<{ value: api.TableImportSourceFormat; icon: any; labe
|
||||
{ value: "sql", icon: FileCode, labelKey: "tableImport.formatSql", descriptionKey: "tableImport.formatSqlDescription" },
|
||||
];
|
||||
|
||||
const encodingOptions: Array<{ value: api.TableImportTextEncoding; labelKey: string }> = [
|
||||
{ value: "auto", labelKey: "tableImport.encodingAuto" },
|
||||
{ value: "utf8", labelKey: "tableImport.encodingUtf8" },
|
||||
{ value: "gbk", labelKey: "tableImport.encodingGbk" },
|
||||
{ value: "utf16Le", labelKey: "tableImport.encodingUtf16Le" },
|
||||
{ value: "utf16Be", labelKey: "tableImport.encodingUtf16Be" },
|
||||
];
|
||||
const encodingOptions = TABLE_IMPORT_ENCODING_OPTIONS;
|
||||
|
||||
const wizardSteps: Array<{ value: TableImportWizardStep; labelKey: string }> = [
|
||||
{ value: "source", labelKey: "tableImport.stepSource" },
|
||||
@@ -331,7 +327,7 @@ function suggestedTableName(name: string) {
|
||||
}
|
||||
|
||||
function sourceName(source: ImportSource): string {
|
||||
return typeof source === "string" ? source.split(/[\\/]/).pop() || source : source.name;
|
||||
return importSourceDisplayName(source);
|
||||
}
|
||||
|
||||
function uniqueTableName(baseName: string, usedNames: Set<string>): string {
|
||||
@@ -556,9 +552,9 @@ async function loadTargetColumns() {
|
||||
}
|
||||
|
||||
async function previewSelectedImportFile(fileOrPath: string | File) {
|
||||
const reusablePreview = preview.value?.sourceRef ? preview.value : null;
|
||||
return api.previewTableImportFile(reusablePreview?.filePath || fileOrPath, {
|
||||
sourceRef: reusablePreview?.sourceRef || null,
|
||||
const input = importPreviewInput(uploadedImportSourceFromPreview(preview.value), fileOrPath);
|
||||
return api.previewTableImportFile(input.fileOrPath, {
|
||||
sourceRef: input.sourceRef,
|
||||
sourceFormat: sourceFormat.value,
|
||||
parseOptions: parseOptions.value,
|
||||
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
|
||||
@@ -648,11 +644,11 @@ async function prepareBatchSources(sources: ImportSource[]) {
|
||||
});
|
||||
const sheets = format === "excel" && initialPreview.sheets?.length ? initialPreview.sheets : [""];
|
||||
for (const sheetName of sheets) {
|
||||
const reusableSource = initialPreview.sourceRef ? initialPreview.filePath : source;
|
||||
const input = importPreviewInput(uploadedImportSourceFromPreview(initialPreview), source);
|
||||
const effectiveSheetName = sheetName && sheetName === initialPreview.sheets?.[0] ? "" : sheetName;
|
||||
const taskPreview = effectiveSheetName
|
||||
? await api.previewTableImportFile(reusableSource, {
|
||||
sourceRef: initialPreview.sourceRef || null,
|
||||
? await api.previewTableImportFile(input.fileOrPath, {
|
||||
sourceRef: input.sourceRef,
|
||||
sourceFormat: format,
|
||||
parseOptions: taskParseOptions(format, effectiveSheetName),
|
||||
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
|
||||
@@ -1025,9 +1021,9 @@ async function reloadBatchPreviewsForEncoding() {
|
||||
for (const task of batchTasks.value) {
|
||||
// SQL 脚本同样是文本源,编码变化时需要重新预览
|
||||
if (!isDelimitedFormat(task.format) && task.format !== "sql") continue;
|
||||
const reusableSource = task.preview.sourceRef ? task.preview.filePath : task.source;
|
||||
const nextPreview = await api.previewTableImportFile(reusableSource, {
|
||||
sourceRef: task.preview.sourceRef || null,
|
||||
const input = importPreviewInput(uploadedImportSourceFromPreview(task.preview), task.source);
|
||||
const nextPreview = await api.previewTableImportFile(input.fileOrPath, {
|
||||
sourceRef: input.sourceRef,
|
||||
sourceFormat: task.format,
|
||||
parseOptions: taskParseOptions(task.format, task.sheetName),
|
||||
previewLimit: Math.max(1, Number(previewLimit.value) || 50),
|
||||
|
||||
@@ -13,6 +13,7 @@ const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/s
|
||||
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
|
||||
const DatabaseDocsDialog = defineAsyncComponent(() => import("@/components/docs/DatabaseDocsDialog.vue"));
|
||||
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
|
||||
const MongoImportDialog = defineAsyncComponent(() => import("@/components/document/MongoImportDialog.vue"));
|
||||
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
|
||||
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
|
||||
const ConfigConnectionSelectDialog = defineAsyncComponent(() => import("@/components/config/ConfigConnectionSelectDialog.vue"));
|
||||
@@ -295,6 +296,7 @@ watch(
|
||||
:prefill-schema="dialogs.tableImportPrefillSchema.value"
|
||||
:prefill-table="dialogs.tableImportPrefillTable.value"
|
||||
/>
|
||||
<MongoImportDialog v-model:open="dialogs.showMongoImportDialog.value" :connection-id="dialogs.mongoImportPrefillConnectionId.value" :database="dialogs.mongoImportPrefillDatabase.value" :collection="dialogs.mongoImportPrefillCollection.value" />
|
||||
<DataGenerateDialog
|
||||
v-if="dialogs.showTableDataGenerateDialog.value"
|
||||
v-model:open="dialogs.showTableDataGenerateDialog.value"
|
||||
|
||||
@@ -391,21 +391,22 @@ watch(
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
const { copyStructureAs, copyStructureDocText, copyStructurePreview, exportData, exportDataXlsx, exportStructure, saveStructurePreview, selectTextareaContent } = useSidebarTreeExportRuntime({
|
||||
const { copyStructureAs, copyStructureDocText, copyStructurePreview, exportData, exportDataXlsx, exportMongoCollection, exportStructure, saveStructurePreview, selectTextareaContent } = useSidebarTreeExportRuntime({
|
||||
activeNode,
|
||||
connectionStore,
|
||||
settingsStore,
|
||||
acceptedSelectionIds: () => acceptedSelectionIds,
|
||||
});
|
||||
|
||||
const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openDocs, openFieldLineage, openScheduledBackups, openSchemaDiff, openSchemaDiffForRoutine, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } = useSidebarTreeToolRuntime({
|
||||
activeNode,
|
||||
connectionStore,
|
||||
queryStore,
|
||||
settingsStore,
|
||||
tableChildObjectName: tableChildDropObjectName,
|
||||
acceptedSelectionIds: () => acceptedSelectionIds,
|
||||
});
|
||||
const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openDocs, openFieldLineage, openMongoImport, openScheduledBackups, openSchemaDiff, openSchemaDiffForRoutine, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } =
|
||||
useSidebarTreeToolRuntime({
|
||||
activeNode,
|
||||
connectionStore,
|
||||
queryStore,
|
||||
settingsStore,
|
||||
tableChildObjectName: tableChildDropObjectName,
|
||||
acceptedSelectionIds: () => acceptedSelectionIds,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"rename-started": [];
|
||||
@@ -5932,6 +5933,15 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
if (canCloneMongoCollection.value) {
|
||||
items.push({ label: t("contextMenu.cloneCollection"), action: openCloneMongoCollectionDialog, icon: CopyPlus });
|
||||
}
|
||||
items.push({ label: t("contextMenu.importData"), action: openMongoImport, icon: Download });
|
||||
items.push({
|
||||
label: t("contextMenu.exportData"),
|
||||
icon: Upload,
|
||||
children: [
|
||||
{ label: "CSV", action: () => void exportMongoCollection("csv") },
|
||||
{ label: "NDJSON", action: () => void exportMongoCollection("ndjson") },
|
||||
],
|
||||
});
|
||||
if (canDropMongoCollection.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.dropCollection"), action: dropMongoCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const });
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function read(relativePath: string) {
|
||||
return readFileSync(resolve(here, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("mongo collection import/export UI", () => {
|
||||
it("adds table-style import/export to the original mongo-collection menu", () => {
|
||||
const menu = read("../SidebarTreeRuntimeHost.vue");
|
||||
const start = menu.indexOf('if (node.type === "mongo-collection") {\n items.push({ label: t("contextMenu.copyName")');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = menu.indexOf("return true;", start);
|
||||
const block = menu.slice(start, end);
|
||||
expect(block).toContain('t("contextMenu.viewData")');
|
||||
expect(block).toContain('t("contextMenu.cloneCollection")');
|
||||
expect(block.indexOf('t("contextMenu.cloneCollection")')).toBeLessThan(block.indexOf('t("contextMenu.importData")'));
|
||||
expect(block).toContain("openMongoImport");
|
||||
expect(block).toContain('t("contextMenu.exportData")');
|
||||
expect(block).toContain('exportMongoCollection("csv")');
|
||||
expect(block).toContain('exportMongoCollection("ndjson")');
|
||||
expect(block).not.toContain("openMongoExport");
|
||||
});
|
||||
|
||||
it("opens import through AppDialogs like table import, without an export setup dialog", () => {
|
||||
const dialogs = read("../../layout/AppDialogs.vue");
|
||||
expect(dialogs).toContain("MongoImportDialog");
|
||||
expect(dialogs).not.toContain("MongoExportDialog");
|
||||
expect(dialogs).toContain("dialogs.showMongoImportDialog.value");
|
||||
|
||||
const sources = read("../../../composables/useDialogSources.ts");
|
||||
expect(sources).toContain("connectionStore.mongoImportSource");
|
||||
expect(sources).not.toContain("connectionStore.mongoExportSource");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const store = reactive({
|
||||
connections: [],
|
||||
mongoImportSource: null as { connectionId: string; database: string; collection: string } | null,
|
||||
transferSource: null,
|
||||
schemaDiffSource: null,
|
||||
dataCompareSource: null,
|
||||
sqlFileSource: null,
|
||||
diagramSource: null,
|
||||
docsSource: null,
|
||||
tableImportSource: null,
|
||||
tableDataGenerateSource: null,
|
||||
fieldLineageSource: null,
|
||||
databaseSearchSource: null,
|
||||
databaseExportSource: null,
|
||||
});
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
import { useDialogSources } from "@/composables/useDialogSources";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
let dialogs: ReturnType<typeof useDialogSources>;
|
||||
|
||||
async function mountDialogs() {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
dialogs = useDialogSources();
|
||||
return () => h("div");
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("useDialogSources mongo import/export", () => {
|
||||
beforeAll(async () => {
|
||||
await mountDialogs();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
});
|
||||
|
||||
it("opens the mongo import dialog from a collection source", async () => {
|
||||
store.mongoImportSource = { connectionId: "c1", database: "shop", collection: "orders" };
|
||||
await nextTick();
|
||||
expect(dialogs.showMongoImportDialog.value).toBe(true);
|
||||
expect(dialogs.mongoImportPrefillCollection.value).toBe("orders");
|
||||
expect(store.mongoImportSource).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ const apiMock = vi.hoisted(() => ({
|
||||
getColumns: vi.fn(),
|
||||
getTableDdl: vi.fn(),
|
||||
startTableExport: vi.fn(),
|
||||
exportMongodbQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMock);
|
||||
@@ -122,6 +123,41 @@ describe("useSidebarTreeExportRuntime", () => {
|
||||
expect(toastMock).toHaveBeenCalledWith("导出失败:上一个 DuckDB 查询仍在停止中,请稍后重试。", 5000);
|
||||
});
|
||||
|
||||
it("exports a mongo collection through the save-file path without a setup dialog", async () => {
|
||||
apiMock.exportMongodbQuery.mockImplementation(async (_request, onProgress) => {
|
||||
onProgress({ exportId: "export-1", status: "done", documentsRead: 3, bytesWritten: 12, elapsedMs: 4 });
|
||||
return { exportId: "export-1", documentsExported: 3, filePath: "orders.ndjson", elapsedMs: 4 };
|
||||
});
|
||||
const activeNode = shallowRef({ id: "col-1", type: "mongo-collection", label: "orders", connectionId: "conn-1", database: "shop", children: [] } as TreeNode);
|
||||
const connectionStore = {
|
||||
ensureConnected: vi.fn(),
|
||||
getConfig: vi.fn(() => ({ db_type: "mongodb" })),
|
||||
treeNodes: [],
|
||||
selectedTreeNodeIds: [],
|
||||
};
|
||||
const runtime = useSidebarTreeExportRuntime({
|
||||
activeNode,
|
||||
connectionStore: connectionStore as never,
|
||||
settingsStore: exportSettings() as never,
|
||||
acceptedSelectionIds: () => null,
|
||||
});
|
||||
|
||||
await runtime.exportMongoCollection("ndjson");
|
||||
|
||||
expect(connectionStore.ensureConnected).toHaveBeenCalledWith("conn-1");
|
||||
expect(apiMock.exportMongodbQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
connectionId: "conn-1",
|
||||
database: "shop",
|
||||
collection: "orders",
|
||||
format: "ndjson",
|
||||
filePath: "orders.ndjson",
|
||||
}),
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith("grid.exported");
|
||||
});
|
||||
|
||||
it("loads and joins every selected DDL in tree order", async () => {
|
||||
apiMock.getTableDdl.mockResolvedValueOnce("CREATE TABLE one (id INT)").mockResolvedValueOnce("CREATE VIEW two AS SELECT 1;");
|
||||
const first = { id: "table-1", type: "table", label: "one", connectionId: "conn-1", database: "db", schema: "main" } as TreeNode;
|
||||
|
||||
+20
@@ -10,6 +10,7 @@ function setup(node: Partial<TreeNode>, options: { treeNodes?: TreeNode[]; selec
|
||||
docsSource: null as unknown,
|
||||
diagramSource: null as unknown,
|
||||
databaseExportSource: null as unknown,
|
||||
mongoImportSource: undefined as unknown,
|
||||
schemaDiffSource: null as unknown,
|
||||
treeNodes: options.treeNodes ?? [],
|
||||
selectedTreeNodeIds: options.selectedTreeNodeIds ?? [],
|
||||
@@ -109,6 +110,25 @@ describe("useSidebarTreeToolRuntime diagram and database export", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSidebarTreeToolRuntime mongo import", () => {
|
||||
it("opens collection import from a mongo-collection node", () => {
|
||||
const { connectionStore, runtime } = setup({
|
||||
type: "mongo-collection",
|
||||
label: "orders",
|
||||
connectionId: "conn-1",
|
||||
database: "shop",
|
||||
});
|
||||
|
||||
runtime.openMongoImport();
|
||||
|
||||
expect(connectionStore.mongoImportSource).toEqual({
|
||||
connectionId: "conn-1",
|
||||
database: "shop",
|
||||
collection: "orders",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSidebarTreeToolRuntime openSchemaDiffForRoutine", () => {
|
||||
it("prefills schema diff with a signature-aware routine key", () => {
|
||||
const { connectionStore, runtime } = setup({
|
||||
|
||||
@@ -14,6 +14,7 @@ const showSqlFileDialog = ref(false);
|
||||
const showDiagramDialog = ref(false);
|
||||
const showDocsDialog = ref(false);
|
||||
const showTableImportDialog = ref(false);
|
||||
const showMongoImportDialog = ref(false);
|
||||
const showTableDataGenerateDialog = ref(false);
|
||||
const showFieldLineageDialog = ref(false);
|
||||
const showDatabaseSearchDialog = ref(false);
|
||||
@@ -68,6 +69,9 @@ const tableImportPrefillConnectionId = ref("");
|
||||
const tableImportPrefillDatabase = ref("");
|
||||
const tableImportPrefillSchema = ref("");
|
||||
const tableImportPrefillTable = ref("");
|
||||
const mongoImportPrefillConnectionId = ref("");
|
||||
const mongoImportPrefillDatabase = ref("");
|
||||
const mongoImportPrefillCollection = ref("");
|
||||
const tableDataGeneratePrefillConnectionId = ref("");
|
||||
const tableDataGeneratePrefillDatabase = ref("");
|
||||
const tableDataGeneratePrefillSchema = ref("");
|
||||
@@ -246,6 +250,19 @@ export function useDialogSources() {
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.mongoImportSource,
|
||||
(v) => {
|
||||
if (v) {
|
||||
mongoImportPrefillConnectionId.value = v.connectionId;
|
||||
mongoImportPrefillDatabase.value = v.database;
|
||||
mongoImportPrefillCollection.value = v.collection;
|
||||
showMongoImportDialog.value = true;
|
||||
connectionStore.mongoImportSource = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.tableDataGenerateSource,
|
||||
(v) => {
|
||||
@@ -501,6 +518,7 @@ export function useDialogSources() {
|
||||
showDiagramDialog,
|
||||
showDocsDialog,
|
||||
showTableImportDialog,
|
||||
showMongoImportDialog,
|
||||
showTableDataGenerateDialog,
|
||||
showFieldLineageDialog,
|
||||
showDatabaseSearchDialog,
|
||||
@@ -551,6 +569,9 @@ export function useDialogSources() {
|
||||
tableImportPrefillDatabase,
|
||||
tableImportPrefillSchema,
|
||||
tableImportPrefillTable,
|
||||
mongoImportPrefillConnectionId,
|
||||
mongoImportPrefillDatabase,
|
||||
mongoImportPrefillCollection,
|
||||
tableDataGeneratePrefillConnectionId,
|
||||
tableDataGeneratePrefillDatabase,
|
||||
tableDataGeneratePrefillSchema,
|
||||
|
||||
@@ -270,23 +270,34 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
return typeof selected === "string" ? selected : null;
|
||||
}
|
||||
|
||||
async function resolveTableExportOutputPath(target: SidebarTableExportTarget, format: string, outputDirectory?: string): Promise<string | null> {
|
||||
const fileName = `${target.fileNameBase ?? target.tableName}.${format}`;
|
||||
function exportFilterName(format: string): string {
|
||||
if (format === "csv") return "CSV";
|
||||
if (format === "json") return "JSON";
|
||||
if (format === "ndjson") return "NDJSON";
|
||||
if (format === "xlsx") return "Excel";
|
||||
return "SQL";
|
||||
}
|
||||
|
||||
async function resolveExportOutputPath(fileNameBase: string, format: string, outputDirectory?: string): Promise<string | null> {
|
||||
const fileName = `${fileNameBase}.${format}`;
|
||||
if (outputDirectory !== undefined) {
|
||||
return outputDirectory ? joinExportFilePath(outputDirectory, fileName) : fileName;
|
||||
}
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const filterName = format === "csv" ? "CSV" : format === "json" ? "JSON" : format === "xlsx" ? "Excel" : "SQL";
|
||||
const path = await save({
|
||||
defaultPath: fileName,
|
||||
filters: [{ name: filterName, extensions: [format] }],
|
||||
filters: [{ name: exportFilterName(format), extensions: [format] }],
|
||||
});
|
||||
return path ? String(path) : null;
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function resolveTableExportOutputPath(target: SidebarTableExportTarget, format: string, outputDirectory?: string): Promise<string | null> {
|
||||
return resolveExportOutputPath(target.fileNameBase ?? target.tableName, format, outputDirectory);
|
||||
}
|
||||
|
||||
async function exportDataLegacyForTarget(target: SidebarTableExportTarget, outputDirectory?: string, suppressDoneToast = false) {
|
||||
const { connectionId, database } = target;
|
||||
const config = connectionStore.getConfig(connectionId);
|
||||
@@ -461,6 +472,49 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
return pickTableExportDirectory();
|
||||
}
|
||||
|
||||
async function exportMongoCollection(format: "csv" | "ndjson") {
|
||||
const node = activeNode.value;
|
||||
if (node.type !== "mongo-collection" || !node.connectionId || !node.database) return;
|
||||
const outputPath = await resolveExportOutputPath(node.label, format);
|
||||
if (!outputPath) return;
|
||||
let task: ExportTask | null = null;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
task = addExportTask(node.label, format, outputPath);
|
||||
const currentTask = task;
|
||||
await api.exportMongodbQuery(
|
||||
{
|
||||
exportId: currentTask.exportId,
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
collection: node.label,
|
||||
format,
|
||||
includeHeader: true,
|
||||
filePath: outputPath,
|
||||
},
|
||||
(progress) => {
|
||||
currentTask.rowsExported = progress.documentsRead;
|
||||
currentTask.totalRows = progress.totalDocuments ?? null;
|
||||
if (progress.status === "running") currentTask.status = "Writing";
|
||||
else if (progress.status === "done") {
|
||||
currentTask.status = "Done";
|
||||
currentTask.finishedAt = Date.now();
|
||||
} else if (progress.status === "error") {
|
||||
currentTask.status = "Error";
|
||||
currentTask.errorMessage = progress.errorMessage ?? null;
|
||||
} else if (progress.status === "cancelled") currentTask.status = "Cancelled";
|
||||
},
|
||||
);
|
||||
toast(t("grid.exported"));
|
||||
} catch (error: unknown) {
|
||||
if (task) {
|
||||
task.status = "Error";
|
||||
task.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
toast(t("grid.exportFailed", { message: translateBackendError(t, error) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData(format: "csv" | "json" | "sql") {
|
||||
const targets = currentTableExportTargets();
|
||||
if (!targets.length) return;
|
||||
@@ -531,6 +585,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
copyStructurePreview,
|
||||
exportData,
|
||||
exportDataXlsx,
|
||||
exportMongoCollection,
|
||||
exportStructure,
|
||||
saveStructurePreview,
|
||||
selectTextareaContent,
|
||||
|
||||
@@ -139,6 +139,16 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
|
||||
};
|
||||
}
|
||||
|
||||
function openMongoImport() {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId || !node.database || node.type !== "mongo-collection") return;
|
||||
connectionStore.mongoImportSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
collection: node.label,
|
||||
};
|
||||
}
|
||||
|
||||
function openStructureEditor() {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
@@ -180,6 +190,7 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
|
||||
openDiagram,
|
||||
openDocs,
|
||||
openFieldLineage,
|
||||
openMongoImport,
|
||||
openScheduledBackups,
|
||||
openSchemaDiff,
|
||||
openSchemaDiffForRoutine,
|
||||
|
||||
@@ -5353,6 +5353,46 @@ export default withEnglishFallback({
|
||||
tableView: "Cədvəl görünüşü",
|
||||
filterPlaceholder: "Süzgəc...",
|
||||
sortPlaceholder: "Sıralama...",
|
||||
import: {
|
||||
title: "Məlumat idxal et",
|
||||
target: "Hədəf kolleksiya",
|
||||
readonly: "Bu bağlantı yalnız oxuma üçündür. İdxaldan əvvəl yazmanı açın.",
|
||||
chooseFile: "Fayl seçin",
|
||||
fileFilter: "MongoDB məlumatı",
|
||||
noFile: "Fayl seçilməyib",
|
||||
back: "Geri",
|
||||
format: "Format",
|
||||
encoding: "Kodlama",
|
||||
delimiter: "Ayırıcı",
|
||||
typeMode: "Tip rejimi",
|
||||
typeString: "Hamısı sətir",
|
||||
typeAuto: "Avtomatik aşkarlama",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "Birinci sətir başlıqdır",
|
||||
trim: "Dəyərləri kəs",
|
||||
emptyAsNull: "Boş xanaları null yaz",
|
||||
recognizeObjectIdHex: "24 simvollu onaltılığı ObjectId say",
|
||||
skipErrorRows: "Xətalı sətirləri keç",
|
||||
previewing: "Önizləmə yenilənir…",
|
||||
estimatedRows: "Təxminən {count} sənəd",
|
||||
continue: "Davam et",
|
||||
confirmAppend: "Sənədlər əlavə olunacaq. Mövcud sənədlər silinməz və əvəz olunmaz.",
|
||||
batchSize: "Paket ölçüsü",
|
||||
duplicateIdPolicy: "Təkrar _id idxalı dayandırır. Artıq göndərilmiş paketlər geri qaytarılmır.",
|
||||
start: "İdxal et",
|
||||
success: "{count} sənəd idxal olundu",
|
||||
stillRunning: "İdxal hələ işləyir. Yeni paketləri dayandırmaq üçün Ləğv et düyməsini istifadə edin.",
|
||||
rowsRead: "Oxunan sətirlər",
|
||||
rowsInserted: "Əlavə olunan sətirlər",
|
||||
rowsFailed: "Uğursuz sətirlər",
|
||||
batchesCommitted: "Göndərilmiş paketlər",
|
||||
phase: {
|
||||
preparing: "Hazırlanır",
|
||||
parsing: "Ayrışdırılır",
|
||||
writing: "Yazılır",
|
||||
done: "Bitdi",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementAz,
|
||||
@@ -5652,6 +5692,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Yazmanı indi kilidlə",
|
||||
unlockAction: "Yazma kilidini aç…",
|
||||
sourceImport: "Cədvəl idxalı",
|
||||
sourceMongoImport: "MongoDB idxalı",
|
||||
sourceTransfer: "Məlumatların köçürülməsi",
|
||||
sourceSqlFile: "SQL faylının icrası",
|
||||
sourceStatus: "Yalnız oxuma rejiminin idarəsi",
|
||||
|
||||
@@ -5353,6 +5353,46 @@ export default {
|
||||
tableView: "Table View",
|
||||
filterPlaceholder: "Filter...",
|
||||
sortPlaceholder: "Sort...",
|
||||
import: {
|
||||
title: "Import data",
|
||||
target: "Target collection",
|
||||
readonly: "This connection is read-only. Unlock writes before importing.",
|
||||
chooseFile: "Choose file",
|
||||
fileFilter: "MongoDB data",
|
||||
noFile: "No file selected",
|
||||
back: "Back",
|
||||
format: "Format",
|
||||
encoding: "Encoding",
|
||||
delimiter: "Delimiter",
|
||||
typeMode: "Type mode",
|
||||
typeString: "All strings",
|
||||
typeAuto: "Auto detect",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "First row is header",
|
||||
trim: "Trim values",
|
||||
emptyAsNull: "Empty cells as null",
|
||||
recognizeObjectIdHex: "Treat 24-character hex as ObjectId",
|
||||
skipErrorRows: "Skip error rows",
|
||||
previewing: "Refreshing preview…",
|
||||
estimatedRows: "{count} documents estimated",
|
||||
continue: "Continue",
|
||||
confirmAppend: "Documents will be appended. Existing documents are not truncated or upserted.",
|
||||
batchSize: "Batch size",
|
||||
duplicateIdPolicy: "Duplicate _id values stop the import. Already committed batches are not rolled back.",
|
||||
start: "Import",
|
||||
success: "Imported {count} documents",
|
||||
stillRunning: "Import is still running. Use Cancel to stop new batches.",
|
||||
rowsRead: "Rows read",
|
||||
rowsInserted: "Rows inserted",
|
||||
rowsFailed: "Rows failed",
|
||||
batchesCommitted: "Batches committed",
|
||||
phase: {
|
||||
preparing: "Preparing",
|
||||
parsing: "Parsing",
|
||||
writing: "Writing",
|
||||
done: "Done",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementEn,
|
||||
@@ -5652,6 +5692,7 @@ export default {
|
||||
lockNow: "Lock writes now",
|
||||
unlockAction: "Unlock writes…",
|
||||
sourceImport: "Table import",
|
||||
sourceMongoImport: "MongoDB import",
|
||||
sourceTransfer: "Data transfer",
|
||||
sourceSqlFile: "SQL file execution",
|
||||
sourceStatus: "Read-only control",
|
||||
|
||||
@@ -5064,6 +5064,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "Nunca expira",
|
||||
indexOtherOptions: "Otras opciones",
|
||||
indexPropertiesUnavailable: "El controlador Legacy de MongoDB no puede informar disperso, expiración, background o tamaño de bucket. Conéctese con el controlador nativo para verlos.",
|
||||
import: {
|
||||
title: "Importar datos",
|
||||
target: "Colección de destino",
|
||||
readonly: "Esta conexión es de solo lectura. Desbloquee la escritura antes de importar.",
|
||||
chooseFile: "Elegir archivo",
|
||||
fileFilter: "Datos de MongoDB",
|
||||
noFile: "Ningún archivo seleccionado",
|
||||
back: "Atrás",
|
||||
format: "Formato",
|
||||
encoding: "Codificación",
|
||||
delimiter: "Delimitador",
|
||||
typeMode: "Modo de tipos",
|
||||
typeString: "Todo como texto",
|
||||
typeAuto: "Detectar automáticamente",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "La primera fila es el encabezado",
|
||||
trim: "Recortar valores",
|
||||
emptyAsNull: "Celdas vacías como null",
|
||||
recognizeObjectIdHex: "Tratar hexadecimal de 24 caracteres como ObjectId",
|
||||
skipErrorRows: "Omitir filas con error",
|
||||
previewing: "Actualizando la vista previa…",
|
||||
estimatedRows: "{count} documentos estimados",
|
||||
continue: "Continuar",
|
||||
confirmAppend: "Los documentos se añadirán. No se truncará ni se hará upsert de los existentes.",
|
||||
batchSize: "Tamaño de lote",
|
||||
duplicateIdPolicy: "Un _id duplicado detiene la importación. Los lotes ya confirmados no se revierten.",
|
||||
start: "Importar",
|
||||
success: "Se importaron {count} documentos",
|
||||
stillRunning: "La importación sigue en curso. Use Cancelar para detener nuevos lotes.",
|
||||
rowsRead: "Filas leídas",
|
||||
rowsInserted: "Filas insertadas",
|
||||
rowsFailed: "Filas fallidas",
|
||||
batchesCommitted: "Lotes confirmados",
|
||||
phase: {
|
||||
preparing: "Preparando",
|
||||
parsing: "Analizando",
|
||||
writing: "Escribiendo",
|
||||
done: "Hecho",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementEs,
|
||||
@@ -8927,6 +8967,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Volver a bloquear ahora",
|
||||
unlockAction: "Desbloquear escrituras…",
|
||||
sourceImport: "Importación de tabla",
|
||||
sourceMongoImport: "Importación de MongoDB",
|
||||
sourceTransfer: "Transferencia de datos",
|
||||
sourceSqlFile: "Ejecución de archivo SQL",
|
||||
sourceStatus: "Control de solo lectura",
|
||||
|
||||
@@ -5062,6 +5062,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "Mai",
|
||||
indexOtherOptions: "Altre opzioni",
|
||||
indexPropertiesUnavailable: "Il driver Legacy di MongoDB non può riportare sparsità, scadenza, background o dimensione bucket. Connettersi con il driver nativo per vederli.",
|
||||
import: {
|
||||
title: "Importa dati",
|
||||
target: "Collezione di destinazione",
|
||||
readonly: "Questa connessione è in sola lettura. Sblocca la scrittura prima di importare.",
|
||||
chooseFile: "Scegli file",
|
||||
fileFilter: "Dati MongoDB",
|
||||
noFile: "Nessun file selezionato",
|
||||
back: "Indietro",
|
||||
format: "Formato",
|
||||
encoding: "Codifica",
|
||||
delimiter: "Delimitatore",
|
||||
typeMode: "Modalità tipi",
|
||||
typeString: "Tutto come stringa",
|
||||
typeAuto: "Rilevamento automatico",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "La prima riga è l'intestazione",
|
||||
trim: "Taglia gli spazi",
|
||||
emptyAsNull: "Celle vuote come null",
|
||||
recognizeObjectIdHex: "Tratta l'esadecimale a 24 caratteri come ObjectId",
|
||||
skipErrorRows: "Salta le righe con errore",
|
||||
previewing: "Aggiornamento anteprima…",
|
||||
estimatedRows: "{count} documenti stimati",
|
||||
continue: "Continua",
|
||||
confirmAppend: "I documenti verranno aggiunti. Nessun truncate né upsert.",
|
||||
batchSize: "Dimensione batch",
|
||||
duplicateIdPolicy: "Un _id duplicato interrompe l'importazione. I batch già confermati non vengono annullati.",
|
||||
start: "Importa",
|
||||
success: "Importati {count} documenti",
|
||||
stillRunning: "L'importazione è ancora in esecuzione. Usa Annulla per fermare i nuovi batch.",
|
||||
rowsRead: "Righe lette",
|
||||
rowsInserted: "Righe inserite",
|
||||
rowsFailed: "Righe non riuscite",
|
||||
batchesCommitted: "Batch confermati",
|
||||
phase: {
|
||||
preparing: "Preparazione",
|
||||
parsing: "Analisi",
|
||||
writing: "Scrittura",
|
||||
done: "Completato",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementIt,
|
||||
@@ -8928,6 +8968,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Blocca scritture ora",
|
||||
unlockAction: "Sblocca scritture…",
|
||||
sourceImport: "Importazione tabella",
|
||||
sourceMongoImport: "Importazione MongoDB",
|
||||
sourceTransfer: "Trasferimento dati",
|
||||
sourceSqlFile: "Esecuzione file SQL",
|
||||
sourceStatus: "Controllo sola lettura",
|
||||
|
||||
@@ -5091,6 +5091,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "期限なし",
|
||||
indexOtherOptions: "その他のオプション",
|
||||
indexPropertiesUnavailable: "MongoDB Legacy ドライバーはスパース、有効期限、バックグラウンド、バケットサイズを報告できません。ネイティブドライバーで接続して確認してください。",
|
||||
import: {
|
||||
title: "データをインポート",
|
||||
target: "対象コレクション",
|
||||
readonly: "この接続は読み取り専用です。インポート前に書き込みを解除してください。",
|
||||
chooseFile: "ファイルを選択",
|
||||
fileFilter: "MongoDB データ",
|
||||
noFile: "ファイル未選択",
|
||||
back: "戻る",
|
||||
format: "形式",
|
||||
encoding: "エンコーディング",
|
||||
delimiter: "区切り文字",
|
||||
typeMode: "型モード",
|
||||
typeString: "すべて文字列",
|
||||
typeAuto: "自動検出",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "先頭行をヘッダーにする",
|
||||
trim: "値をトリム",
|
||||
emptyAsNull: "空セルを null にする",
|
||||
recognizeObjectIdHex: "24 桁の 16 進数を ObjectId として扱う",
|
||||
skipErrorRows: "エラー行をスキップ",
|
||||
previewing: "プレビューを更新中…",
|
||||
estimatedRows: "推定 {count} 件",
|
||||
continue: "続行",
|
||||
confirmAppend: "ドキュメントは追加されます。既存ドキュメントの切り捨てや upsert は行いません。",
|
||||
batchSize: "バッチサイズ",
|
||||
duplicateIdPolicy: "重複した _id でインポートは停止します。すでに確定したバッチはロールバックされません。",
|
||||
start: "インポート",
|
||||
success: "{count} 件のドキュメントをインポートしました",
|
||||
stillRunning: "インポートは実行中です。以降のバッチを止めるにはキャンセルしてください。",
|
||||
rowsRead: "読み取り行数",
|
||||
rowsInserted: "挿入行数",
|
||||
rowsFailed: "失敗行数",
|
||||
batchesCommitted: "確定バッチ数",
|
||||
phase: {
|
||||
preparing: "準備中",
|
||||
parsing: "解析中",
|
||||
writing: "書き込み中",
|
||||
done: "完了",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementJa,
|
||||
@@ -8979,6 +9019,7 @@ export default withEnglishFallback({
|
||||
lockNow: "今すぐ再ロック",
|
||||
unlockAction: "書き込みのロックを解除…",
|
||||
sourceImport: "テーブルのインポート",
|
||||
sourceMongoImport: "MongoDB のインポート",
|
||||
sourceTransfer: "データ転送",
|
||||
sourceSqlFile: "SQL ファイル実行",
|
||||
sourceStatus: "読み取り専用コントロール",
|
||||
|
||||
@@ -4690,6 +4690,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "만료 안 함",
|
||||
indexOtherOptions: "기타 옵션",
|
||||
indexPropertiesUnavailable: "MongoDB Legacy 드라이버는 희소, 만료, 백그라운드 또는 버킷 크기를 보고할 수 없습니다. 네이티브 드라이버로 연결하여 확인하세요.",
|
||||
import: {
|
||||
title: "데이터 가져오기",
|
||||
target: "대상 컬렉션",
|
||||
readonly: "이 연결은 읽기 전용입니다. 가져오기 전에 쓰기를 잠금 해제하세요.",
|
||||
chooseFile: "파일 선택",
|
||||
fileFilter: "MongoDB 데이터",
|
||||
noFile: "선택된 파일 없음",
|
||||
back: "뒤로",
|
||||
format: "형식",
|
||||
encoding: "인코딩",
|
||||
delimiter: "구분 기호",
|
||||
typeMode: "유형 모드",
|
||||
typeString: "모두 문자열",
|
||||
typeAuto: "자동 감지",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "첫 행을 헤더로 사용",
|
||||
trim: "값 앞뒤 공백 제거",
|
||||
emptyAsNull: "빈 셀을 null로 기록",
|
||||
recognizeObjectIdHex: "24자 16진수를 ObjectId로 인식",
|
||||
skipErrorRows: "오류 행 건너뛰기",
|
||||
previewing: "미리보기 새로 고치는 중…",
|
||||
estimatedRows: "약 {count}개 문서",
|
||||
continue: "계속",
|
||||
confirmAppend: "문서는 추가됩니다. 기존 문서는 비우거나 upsert하지 않습니다.",
|
||||
batchSize: "배치 크기",
|
||||
duplicateIdPolicy: "중복 _id는 가져오기를 중지합니다. 이미 커밋된 배치는 롤백되지 않습니다.",
|
||||
start: "가져오기",
|
||||
success: "{count}개 문서를 가져왔습니다",
|
||||
stillRunning: "가져오기가 계속 실행 중입니다. 이후 배치를 멈추려면 취소를 사용하세요.",
|
||||
rowsRead: "읽은 행",
|
||||
rowsInserted: "삽입된 행",
|
||||
rowsFailed: "실패한 행",
|
||||
batchesCommitted: "커밋된 배치",
|
||||
phase: {
|
||||
preparing: "준비 중",
|
||||
parsing: "구문 분석 중",
|
||||
writing: "쓰는 중",
|
||||
done: "완료",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementKo,
|
||||
@@ -4988,6 +5028,7 @@ export default withEnglishFallback({
|
||||
lockNow: "지금 다시 잠금",
|
||||
unlockAction: "쓰기 잠금 해제…",
|
||||
sourceImport: "테이블 가져오기",
|
||||
sourceMongoImport: "MongoDB 가져오기",
|
||||
sourceTransfer: "데이터 이전",
|
||||
sourceSqlFile: "SQL 파일 실행",
|
||||
sourceStatus: "읽기 전용 컨트롤",
|
||||
|
||||
@@ -5064,6 +5064,46 @@ export default withEnglishFallback({
|
||||
indexNoExpiry: "Nunca",
|
||||
indexOtherOptions: "Outras opções",
|
||||
indexPropertiesUnavailable: "O driver Legacy do MongoDB não pode informar esparso, expiração, background ou tamanho do bucket. Conecte-se com o driver nativo para vê-los.",
|
||||
import: {
|
||||
title: "Importar dados",
|
||||
target: "Coleção de destino",
|
||||
readonly: "Esta conexão é somente leitura. Desbloqueie a gravação antes de importar.",
|
||||
chooseFile: "Escolher arquivo",
|
||||
fileFilter: "Dados do MongoDB",
|
||||
noFile: "Nenhum arquivo selecionado",
|
||||
back: "Voltar",
|
||||
format: "Formato",
|
||||
encoding: "Codificação",
|
||||
delimiter: "Delimitador",
|
||||
typeMode: "Modo de tipos",
|
||||
typeString: "Tudo como texto",
|
||||
typeAuto: "Detectar automaticamente",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "A primeira linha é o cabeçalho",
|
||||
trim: "Remover espaços",
|
||||
emptyAsNull: "Células vazias como null",
|
||||
recognizeObjectIdHex: "Tratar hexadecimal de 24 caracteres como ObjectId",
|
||||
skipErrorRows: "Ignorar linhas com erro",
|
||||
previewing: "Atualizando a prévia…",
|
||||
estimatedRows: "{count} documentos estimados",
|
||||
continue: "Continuar",
|
||||
confirmAppend: "Os documentos serão acrescentados. Não há truncate nem upsert.",
|
||||
batchSize: "Tamanho do lote",
|
||||
duplicateIdPolicy: "Um _id duplicado interrompe a importação. Lotes já confirmados não são revertidos.",
|
||||
start: "Importar",
|
||||
success: "{count} documentos importados",
|
||||
stillRunning: "A importação ainda está em execução. Use Cancelar para interromper novos lotes.",
|
||||
rowsRead: "Linhas lidas",
|
||||
rowsInserted: "Linhas inseridas",
|
||||
rowsFailed: "Linhas com falha",
|
||||
batchesCommitted: "Lotes confirmados",
|
||||
phase: {
|
||||
preparing: "Preparando",
|
||||
parsing: "Analisando",
|
||||
writing: "Gravando",
|
||||
done: "Concluído",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementPtBR,
|
||||
@@ -8929,6 +8969,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Bloquear gravações agora",
|
||||
unlockAction: "Desbloquear gravações…",
|
||||
sourceImport: "Importação de tabela",
|
||||
sourceMongoImport: "Importação do MongoDB",
|
||||
sourceTransfer: "Transferência de dados",
|
||||
sourceSqlFile: "Execução de arquivo SQL",
|
||||
sourceStatus: "Controle de somente leitura",
|
||||
|
||||
@@ -5247,6 +5247,46 @@ export default withEnglishFallback({
|
||||
tableView: "Tablo Görünümü",
|
||||
filterPlaceholder: "Filtrele...",
|
||||
sortPlaceholder: "Sırala...",
|
||||
import: {
|
||||
title: "Veri içe aktar",
|
||||
target: "Hedef koleksiyon",
|
||||
readonly: "Bu bağlantı salt okunur. İçe aktarmadan önce yazmayı açın.",
|
||||
chooseFile: "Dosya seç",
|
||||
fileFilter: "MongoDB verisi",
|
||||
noFile: "Dosya seçilmedi",
|
||||
back: "Geri",
|
||||
format: "Biçim",
|
||||
encoding: "Kodlama",
|
||||
delimiter: "Ayırıcı",
|
||||
typeMode: "Tür kipi",
|
||||
typeString: "Tümü metin",
|
||||
typeAuto: "Otomatik algıla",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "İlk satır başlıktır",
|
||||
trim: "Değerleri kırp",
|
||||
emptyAsNull: "Boş hücreleri null yaz",
|
||||
recognizeObjectIdHex: "24 karakterlik onaltılığı ObjectId say",
|
||||
skipErrorRows: "Hatalı satırları atla",
|
||||
previewing: "Önizleme yenileniyor…",
|
||||
estimatedRows: "Yaklaşık {count} belge",
|
||||
continue: "Devam",
|
||||
confirmAppend: "Belgeler eklenecek. Mevcut belgeler silinmez veya üzerine yazılmaz.",
|
||||
batchSize: "Toplu boyut",
|
||||
duplicateIdPolicy: "Yinelenen _id içe aktarmayı durdurur. Gönderilmiş toplu işler geri alınmaz.",
|
||||
start: "İçe aktar",
|
||||
success: "{count} belge içe aktarıldı",
|
||||
stillRunning: "İçe aktarma hâlâ çalışıyor. Yeni toplu işleri durdurmak için İptal’i kullanın.",
|
||||
rowsRead: "Okunan satırlar",
|
||||
rowsInserted: "Eklenen satırlar",
|
||||
rowsFailed: "Başarısız satırlar",
|
||||
batchesCommitted: "Gönderilen toplu işler",
|
||||
phase: {
|
||||
preparing: "Hazırlanıyor",
|
||||
parsing: "Ayrıştırılıyor",
|
||||
writing: "Yazılıyor",
|
||||
done: "Bitti",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementTr,
|
||||
@@ -5546,6 +5586,7 @@ export default withEnglishFallback({
|
||||
lockNow: "Yazmaları şimdi kilitle",
|
||||
unlockAction: "Yazmaların kilidini aç…",
|
||||
sourceImport: "Tablo içe aktarma",
|
||||
sourceMongoImport: "MongoDB içe aktarma",
|
||||
sourceTransfer: "Veri aktarımı",
|
||||
sourceSqlFile: "SQL dosyası yürütme",
|
||||
sourceStatus: "Salt okunur denetimi",
|
||||
|
||||
@@ -5337,6 +5337,46 @@ export default withEnglishFallback({
|
||||
tableView: "表格视图",
|
||||
filterPlaceholder: "过滤条件...",
|
||||
sortPlaceholder: "排序条件...",
|
||||
import: {
|
||||
title: "导入数据",
|
||||
target: "目标 Collection",
|
||||
readonly: "当前连接为只读,导入前需要先解锁写入。",
|
||||
chooseFile: "选择文件",
|
||||
fileFilter: "MongoDB 数据",
|
||||
noFile: "尚未选择文件",
|
||||
back: "上一步",
|
||||
format: "格式",
|
||||
encoding: "编码",
|
||||
delimiter: "分隔符",
|
||||
typeMode: "类型策略",
|
||||
typeString: "全部按字符串",
|
||||
typeAuto: "自动推断",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "首行是表头",
|
||||
trim: "去除首尾空白",
|
||||
emptyAsNull: "空单元格写入 null",
|
||||
recognizeObjectIdHex: "将 24 位十六进制识别为 ObjectId",
|
||||
skipErrorRows: "跳过错误行",
|
||||
previewing: "正在刷新预览…",
|
||||
estimatedRows: "估计 {count} 个文档",
|
||||
continue: "继续",
|
||||
confirmAppend: "将以追加方式写入,不会清空或覆盖已有文档。",
|
||||
batchSize: "批次大小",
|
||||
duplicateIdPolicy: "重复 _id 会停止导入。已提交的批次不会回滚。",
|
||||
start: "开始导入",
|
||||
success: "已导入 {count} 个文档",
|
||||
stillRunning: "导入仍在运行。如需停止后续批次,请点取消。",
|
||||
rowsRead: "已读取",
|
||||
rowsInserted: "已插入",
|
||||
rowsFailed: "失败",
|
||||
batchesCommitted: "已提交批次",
|
||||
phase: {
|
||||
preparing: "准备中",
|
||||
parsing: "解析中",
|
||||
writing: "写入中",
|
||||
done: "完成",
|
||||
},
|
||||
},
|
||||
},
|
||||
meilisearch: {
|
||||
...meilisearchManagementZhCN,
|
||||
@@ -5636,6 +5676,7 @@ export default withEnglishFallback({
|
||||
lockNow: "立即重新锁定",
|
||||
unlockAction: "解锁写入…",
|
||||
sourceImport: "表导入",
|
||||
sourceMongoImport: "MongoDB 导入",
|
||||
sourceTransfer: "数据传输",
|
||||
sourceSqlFile: "SQL 文件执行",
|
||||
sourceStatus: "只读控制",
|
||||
|
||||
@@ -4379,6 +4379,46 @@ export default withEnglishFallback({
|
||||
tableView: "表格檢視",
|
||||
filterPlaceholder: "過濾條件……",
|
||||
sortPlaceholder: "排序條件……",
|
||||
import: {
|
||||
title: "匯入資料",
|
||||
target: "目標 Collection",
|
||||
readonly: "目前連線為唯讀,匯入前需要先解鎖寫入。",
|
||||
chooseFile: "選擇檔案",
|
||||
fileFilter: "MongoDB 資料",
|
||||
noFile: "尚未選擇檔案",
|
||||
back: "上一步",
|
||||
format: "格式",
|
||||
encoding: "編碼",
|
||||
delimiter: "分隔符",
|
||||
typeMode: "類型策略",
|
||||
typeString: "全部按字串",
|
||||
typeAuto: "自動推斷",
|
||||
typeExtendedJson: "Extended JSON",
|
||||
hasHeader: "首列是表頭",
|
||||
trim: "去除首尾空白",
|
||||
emptyAsNull: "空儲存格寫入 null",
|
||||
recognizeObjectIdHex: "將 24 位十六進位識別為 ObjectId",
|
||||
skipErrorRows: "跳過錯誤列",
|
||||
previewing: "正在重新整理預覽…",
|
||||
estimatedRows: "估計 {count} 個文件",
|
||||
continue: "繼續",
|
||||
confirmAppend: "將以附加方式寫入,不會清空或覆蓋既有文件。",
|
||||
batchSize: "批次大小",
|
||||
duplicateIdPolicy: "重複 _id 會停止匯入。已提交的批次不會回溯。",
|
||||
start: "開始匯入",
|
||||
success: "已匯入 {count} 個文件",
|
||||
stillRunning: "匯入仍在執行。如需停止後續批次,請點取消。",
|
||||
rowsRead: "已讀取",
|
||||
rowsInserted: "已插入",
|
||||
rowsFailed: "失敗",
|
||||
batchesCommitted: "已提交批次",
|
||||
phase: {
|
||||
preparing: "準備中",
|
||||
parsing: "解析中",
|
||||
writing: "寫入中",
|
||||
done: "完成",
|
||||
},
|
||||
},
|
||||
fieldEditor: "欄位",
|
||||
nativeJson: "原生 JSON",
|
||||
nativeJsonHint: "BSON 值請使用 MongoDB Extended JSON,例如 $oid、$date、$numberLong。",
|
||||
@@ -8917,6 +8957,7 @@ export default withEnglishFallback({
|
||||
lockNow: "立即重新鎖定",
|
||||
unlockAction: "解鎖寫入…",
|
||||
sourceImport: "資料表匯入",
|
||||
sourceMongoImport: "MongoDB 匯入",
|
||||
sourceTransfer: "資料傳輸",
|
||||
sourceSqlFile: "SQL 檔案執行",
|
||||
sourceStatus: "唯讀控制",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { importPreviewInput, importSourceDisplayName, importTextDelimiterForName, uploadedImportSourceFromPreview } from "@/lib/import/importSource";
|
||||
|
||||
describe("importSource", () => {
|
||||
it("reuses an uploaded sourceRef and server path on later previews", () => {
|
||||
const file = new File(["id\n1"], "orders.csv");
|
||||
const first = importPreviewInput(null, file);
|
||||
expect(first).toEqual({ fileOrPath: file, sourceRef: null });
|
||||
|
||||
const uploaded = uploadedImportSourceFromPreview({ sourceRef: "src-1", filePath: "/tmp/orders.csv" });
|
||||
expect(importPreviewInput(uploaded, file)).toEqual({ fileOrPath: "/tmp/orders.csv", sourceRef: "src-1" });
|
||||
});
|
||||
|
||||
it("keeps a local path preview on desktop when nothing has been uploaded", () => {
|
||||
expect(importPreviewInput(null, "/data/orders.csv")).toEqual({ fileOrPath: "/data/orders.csv", sourceRef: null });
|
||||
});
|
||||
|
||||
it("matches table-import TSV delimiter encoding and source labels", () => {
|
||||
expect(importTextDelimiterForName("orders.tsv")).toBe("\\t");
|
||||
expect(importTextDelimiterForName("orders.csv")).toBe(",");
|
||||
expect(importSourceDisplayName("/tmp/dir/orders.csv")).toBe("orders.csv");
|
||||
expect(importSourceDisplayName(new File(["x"], "people.json"))).toBe("people.json");
|
||||
});
|
||||
});
|
||||
@@ -458,6 +458,12 @@ export const previewTableImportFile = forward("previewTableImportFile");
|
||||
export const importTableFile = forward("importTableFile");
|
||||
export const cancelTableImport = forward("cancelTableImport");
|
||||
export const releaseTableImportSource = forward("releaseTableImportSource");
|
||||
export const previewMongodbImportFile = forward("previewMongodbImportFile");
|
||||
export const importMongodbFile = forward("importMongodbFile");
|
||||
export const cancelMongodbImport = forward("cancelMongodbImport");
|
||||
export const releaseMongodbImportSource = forward("releaseMongodbImportSource");
|
||||
export const exportMongodbQuery = forward("exportMongodbQuery");
|
||||
export const cancelMongodbExport = forward("cancelMongodbExport");
|
||||
|
||||
// Database Export
|
||||
export const beginDatabaseBackupSnapshot = forward("beginDatabaseBackupSnapshot");
|
||||
@@ -995,6 +1001,19 @@ export type {
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
MongoImportFormat,
|
||||
MongoImportTypeMode,
|
||||
MongoImportIssue,
|
||||
MongoImportParseOptions,
|
||||
MongoImportPreviewRequest,
|
||||
MongoImportPreview,
|
||||
MongoImportRequest,
|
||||
MongoImportProgress,
|
||||
MongoImportSummary,
|
||||
MongoExportFormat,
|
||||
MongoExportRequest,
|
||||
MongoExportProgress,
|
||||
MongoExportSummary,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
TableExportProgress,
|
||||
|
||||
@@ -132,6 +132,14 @@ import type {
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
MongoImportPreviewRequest,
|
||||
MongoImportPreview,
|
||||
MongoImportRequest,
|
||||
MongoImportProgress,
|
||||
MongoImportSummary,
|
||||
MongoExportRequest,
|
||||
MongoExportProgress,
|
||||
MongoExportSummary,
|
||||
DatabaseBackupSnapshot,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
@@ -2469,6 +2477,122 @@ export async function releaseTableImportSource(sourceRef: string): Promise<boole
|
||||
return result.released;
|
||||
}
|
||||
|
||||
export async function previewMongodbImportFile(fileOrPath: string | File | MongoImportPreviewRequest, options: Partial<MongoImportPreviewRequest> = {}): Promise<MongoImportPreview> {
|
||||
if (typeof fileOrPath === "object" && !(fileOrPath instanceof File)) {
|
||||
throw new Error("previewMongodbImportFile in web mode requires a File object for upload previews");
|
||||
}
|
||||
if (typeof fileOrPath === "string") {
|
||||
if (!options.sourceRef) {
|
||||
throw new Error("previewMongodbImportFile in web mode requires a File object for new uploads");
|
||||
}
|
||||
const res = await fetch(apiUrl("/api/mongo/import/preview-source"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceRef: options.sourceRef,
|
||||
format: options.format,
|
||||
parseOptions: options.parseOptions,
|
||||
previewLimit: options.previewLimit,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileOrPath);
|
||||
if (options.format) formData.append("format", options.format);
|
||||
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/mongo/import/preview"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function importMongodbFile(request: MongoImportRequest, onProgress: (progress: MongoImportProgress) => void): Promise<MongoImportSummary> {
|
||||
const res = await fetch(apiUrl("/api/mongo/import/execute"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return new Promise((resolve, reject) => {
|
||||
const es = new EventSource(apiUrl(`/api/mongo/import/progress/${request.importId}`));
|
||||
es.onmessage = (e) => {
|
||||
const progress: MongoImportProgress = JSON.parse(e.data);
|
||||
onProgress(progress);
|
||||
if (progress.status === "done") {
|
||||
es.close();
|
||||
resolve({
|
||||
importId: progress.importId,
|
||||
rowsInserted: progress.rowsInserted,
|
||||
rowsFailed: progress.rowsFailed,
|
||||
batchesCommitted: progress.batchesCommitted,
|
||||
elapsedMs: progress.elapsedMs,
|
||||
});
|
||||
} else if (progress.status === "error" || progress.status === "cancelled") {
|
||||
es.close();
|
||||
reject(new Error(progress.errorMessage || "MongoDB import failed"));
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error("MongoDB import SSE connection failed"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelMongodbImport(importId: string): Promise<boolean> {
|
||||
return post("/api/mongo/import/cancel", { importId });
|
||||
}
|
||||
|
||||
export async function releaseMongodbImportSource(sourceRef: string): Promise<boolean> {
|
||||
const result = await post<{ released: boolean }>("/api/mongo/import/source/release", { sourceRef });
|
||||
return result.released;
|
||||
}
|
||||
|
||||
export async function exportMongodbQuery(request: MongoExportRequest, onProgress: (progress: MongoExportProgress) => void): Promise<MongoExportSummary> {
|
||||
const res = await fetch(apiUrl("/api/mongo/export"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return new Promise((resolve, reject) => {
|
||||
const es = new EventSource(apiUrl(`/api/mongo/export/progress/${request.exportId}`));
|
||||
es.onmessage = (e) => {
|
||||
const progress: MongoExportProgress = JSON.parse(e.data);
|
||||
onProgress(progress);
|
||||
if (progress.status === "done" || progress.status === "error" || progress.status === "cancelled") {
|
||||
es.close();
|
||||
if (progress.status === "done") {
|
||||
const a = document.createElement("a");
|
||||
a.href = apiUrl(`/api/mongo/export/download/${request.exportId}`);
|
||||
a.click();
|
||||
resolve({
|
||||
exportId: progress.exportId,
|
||||
documentsExported: progress.documentsRead,
|
||||
filePath: request.filePath,
|
||||
elapsedMs: progress.elapsedMs,
|
||||
});
|
||||
} else {
|
||||
reject(new Error(progress.errorMessage || "MongoDB export failed"));
|
||||
}
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error("MongoDB export SSE connection failed"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelMongodbExport(exportId: string): Promise<boolean> {
|
||||
return post("/api/mongo/export/cancel", { exportId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4900,6 +4900,190 @@ export async function releaseTableImportSource(_sourceRef: string): Promise<bool
|
||||
return false;
|
||||
}
|
||||
|
||||
export type MongoImportFormat = "csv" | "json" | "ndjson";
|
||||
export type MongoImportTypeMode = "string" | "auto" | "extendedJson";
|
||||
export type MongoImportInferredType = "boolean" | "integer" | "decimal" | "date" | "object" | "array" | "string";
|
||||
export type MongoImportStatus = "running" | "done" | "error" | "cancelled";
|
||||
export type MongoImportPhase = "preparing" | "parsing" | "writing" | "done";
|
||||
export type MongoExportFormat = "csv" | "ndjson";
|
||||
export type MongoExportStatus = "running" | "done" | "error" | "cancelled";
|
||||
|
||||
export interface MongoImportIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
row?: number | null;
|
||||
column?: string | null;
|
||||
value?: string | null;
|
||||
batch?: number | null;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
export interface MongoImportParseOptions {
|
||||
encoding?: TableImportTextEncoding | null;
|
||||
delimiter?: string | null;
|
||||
hasHeader?: boolean | null;
|
||||
trim?: boolean | null;
|
||||
emptyAsNull?: boolean | null;
|
||||
typeMode?: MongoImportTypeMode | null;
|
||||
recognizeObjectIdHex?: boolean | null;
|
||||
skipErrorRows?: boolean | null;
|
||||
}
|
||||
|
||||
export interface MongoImportPreviewRequest {
|
||||
filePath: string;
|
||||
sourceRef?: string | null;
|
||||
format: MongoImportFormat;
|
||||
parseOptions?: MongoImportParseOptions;
|
||||
previewLimit?: number | null;
|
||||
}
|
||||
|
||||
export interface MongoImportColumn {
|
||||
name: string;
|
||||
inferredType: MongoImportInferredType;
|
||||
sampleValues?: unknown[];
|
||||
}
|
||||
|
||||
export interface MongoImportPreview {
|
||||
sourceRef?: string | null;
|
||||
format: MongoImportFormat;
|
||||
detectedEncoding?: TableImportTextEncoding | null;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
sizeBytes: number;
|
||||
columns: MongoImportColumn[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowNumbers?: number[];
|
||||
warnings: MongoImportIssue[];
|
||||
errors: MongoImportIssue[];
|
||||
estimatedRows?: number | null;
|
||||
estimatedRowsExact: boolean;
|
||||
}
|
||||
|
||||
export interface MongoImportRequest {
|
||||
importId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
filePath: string;
|
||||
sourceRef?: string | null;
|
||||
format: MongoImportFormat;
|
||||
parseOptions?: MongoImportParseOptions;
|
||||
batchSize: number;
|
||||
executionId?: string | null;
|
||||
}
|
||||
|
||||
export interface MongoImportProgress {
|
||||
importId: string;
|
||||
phase: MongoImportPhase;
|
||||
status: MongoImportStatus;
|
||||
rowsRead: number;
|
||||
rowsInserted: number;
|
||||
rowsFailed: number;
|
||||
batchesCommitted: number;
|
||||
totalRows?: number | null;
|
||||
errorRows?: MongoImportIssue[];
|
||||
errorMessage?: string | null;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface MongoImportSummary {
|
||||
importId: string;
|
||||
rowsInserted: number;
|
||||
rowsFailed: number;
|
||||
batchesCommitted: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface MongoExportRequest {
|
||||
exportId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
filter?: string | null;
|
||||
sort?: string | null;
|
||||
projection?: string | null;
|
||||
collation?: string | null;
|
||||
format: MongoExportFormat;
|
||||
includeHeader?: boolean;
|
||||
filePath: string;
|
||||
executionId?: string | null;
|
||||
}
|
||||
|
||||
export interface MongoExportProgress {
|
||||
exportId: string;
|
||||
status: MongoExportStatus;
|
||||
documentsRead: number;
|
||||
bytesWritten: number;
|
||||
totalDocuments?: number | null;
|
||||
errorMessage?: string | null;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface MongoExportSummary {
|
||||
exportId: string;
|
||||
documentsExported: number;
|
||||
filePath: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export async function previewMongodbImportFile(filePathOrRequest: string | File | MongoImportPreviewRequest, options: Partial<MongoImportPreviewRequest> = {}): Promise<MongoImportPreview> {
|
||||
if (typeof filePathOrRequest !== "string" && !("filePath" in filePathOrRequest)) {
|
||||
throw new Error("previewMongodbImportFile in desktop mode requires a file path, not a File object");
|
||||
}
|
||||
const request: MongoImportPreviewRequest = typeof filePathOrRequest === "string" ? { format: options.format ?? "csv", ...options, filePath: filePathOrRequest } : filePathOrRequest;
|
||||
return invoke("preview_mongodb_import_file", { request });
|
||||
}
|
||||
|
||||
export async function importMongodbFile(request: MongoImportRequest, onProgress: (progress: MongoImportProgress) => void): Promise<MongoImportSummary> {
|
||||
const unlisten: UnlistenFn = await listen<MongoImportProgress>("mongo-import-progress", (event) => {
|
||||
if (event.payload.importId === request.importId) {
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "done" || event.payload.status === "error" || event.payload.status === "cancelled") {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
const summary = await invoke<MongoImportSummary>("import_mongodb_file", { request });
|
||||
unlisten();
|
||||
return summary;
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
throw e instanceof BackendErrorException ? e : new BackendErrorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelMongodbImport(importId: string): Promise<boolean> {
|
||||
return invoke("cancel_mongodb_import", { importId });
|
||||
}
|
||||
|
||||
export async function releaseMongodbImportSource(_sourceRef: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function exportMongodbQuery(request: MongoExportRequest, onProgress: (progress: MongoExportProgress) => void): Promise<MongoExportSummary> {
|
||||
const unlisten: UnlistenFn = await listen<MongoExportProgress>("mongo-export-progress", (event) => {
|
||||
if (event.payload.exportId === request.exportId) {
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "done" || event.payload.status === "error" || event.payload.status === "cancelled") {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
const summary = await invoke<MongoExportSummary>("export_mongodb_query", { request });
|
||||
unlisten();
|
||||
return summary;
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
throw e instanceof BackendErrorException ? e : new BackendErrorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelMongodbExport(exportId: string): Promise<boolean> {
|
||||
return invoke("cancel_mongodb_export", { exportId });
|
||||
}
|
||||
|
||||
// --- Database Export ---
|
||||
export interface DatabaseExportRequest {
|
||||
exportId: string;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export type ImportFileSource = string | File;
|
||||
|
||||
export interface UploadedImportSource {
|
||||
sourceRef: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export function importSourceDisplayName(source: ImportFileSource): string {
|
||||
return typeof source === "string" ? source.split(/[\\/]/).pop() || source : source.name;
|
||||
}
|
||||
|
||||
export function importTextDelimiterForName(name: string): string {
|
||||
return name.toLowerCase().endsWith(".tsv") ? "\\t" : ",";
|
||||
}
|
||||
|
||||
/** Reuse a server-side upload on later previews instead of sending the File again. */
|
||||
export function importPreviewInput(uploaded: UploadedImportSource | null | undefined, source: ImportFileSource): { fileOrPath: ImportFileSource; sourceRef: string | null } {
|
||||
if (uploaded?.sourceRef) return { fileOrPath: uploaded.filePath, sourceRef: uploaded.sourceRef };
|
||||
return { fileOrPath: source, sourceRef: null };
|
||||
}
|
||||
|
||||
export function uploadedImportSourceFromPreview(preview: { sourceRef?: string | null; filePath?: string } | null | undefined): UploadedImportSource | null {
|
||||
if (!preview?.sourceRef || !preview.filePath) return null;
|
||||
return { sourceRef: preview.sourceRef, filePath: preview.filePath };
|
||||
}
|
||||
@@ -35,6 +35,14 @@ export interface TableImportProgressLike {
|
||||
|
||||
export type TableImportWizardStep = "source" | "options" | "mapping" | "review" | "execution";
|
||||
|
||||
export const TABLE_IMPORT_ENCODING_OPTIONS: ReadonlyArray<{ value: TableImportTextEncoding; labelKey: string }> = [
|
||||
{ value: "auto", labelKey: "tableImport.encodingAuto" },
|
||||
{ value: "utf8", labelKey: "tableImport.encodingUtf8" },
|
||||
{ value: "gbk", labelKey: "tableImport.encodingGbk" },
|
||||
{ value: "utf16Le", labelKey: "tableImport.encodingUtf16Le" },
|
||||
{ value: "utf16Be", labelKey: "tableImport.encodingUtf16Be" },
|
||||
];
|
||||
|
||||
export const TABLE_IMPORT_WIZARD_STEPS: TableImportWizardStep[] = ["source", "options", "mapping", "review", "execution"];
|
||||
|
||||
export function formatTableImportElapsed(ms: number): string {
|
||||
|
||||
@@ -552,6 +552,17 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
schema?: string;
|
||||
tableName?: string;
|
||||
} | null>(null);
|
||||
const mongoImportSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
} | null>(null);
|
||||
const mongoImportCompleted = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
at: number;
|
||||
} | null>(null);
|
||||
const tableDataGenerateSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
@@ -9144,6 +9155,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
diagramSource,
|
||||
docsSource,
|
||||
tableImportSource,
|
||||
mongoImportSource,
|
||||
mongoImportCompleted,
|
||||
tableDataGenerateSource,
|
||||
fieldLineageSource,
|
||||
databaseSearchSource,
|
||||
|
||||
Reference in new issue
Block a user