fix(gridfs): support bulk download and paged browsing (#2743)

This commit is contained in:
LSD 2026-07-07 12:43:07 +08:00 committed by GitHub
parent 65af804099
commit a7be9eed13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 493 additions and 48 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowDown, ArrowUp, ArrowUpDown, Download, Filter, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Download, Filter, LoaderCircle, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -9,12 +9,16 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/composables/useToast";
import { buildDocumentFilterCondition, currentDocumentFilterJson, currentDocumentSortJson, documentFilterModeNeedsValue, documentFilterModeOptions, documentStoreProviderFor, type DocumentFilterMode, type DocumentFilterRule } from "@/lib/app/documentStoreProvider";
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/dataGrid/binaryCellDownload";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, gridFsFileFieldDisplayOption, gridFsFileFieldDisplayOptions, currentGridFsFileSortDirection, gridFsFilesQueryPreview } from "@/lib/document/gridFsBrowser";
import { downloadBinaryCellPayload, type BinaryCellDownloadPayload } from "@/lib/dataGrid/binaryCellDownload";
import { normalizeResultPageSize, resultPageSizeMenuOptions } from "@/lib/dataGrid/paginationPageSize";
import { buildGridFsFilesStructuredFilter, createGridFsFileFilterRule, currentGridFsFileSortDirection, gridFsFileFieldDisplayOption, gridFsFileFieldDisplayOptions, gridFsFilesQueryPreview } from "@/lib/document/gridFsBrowser";
import { buildGridFsDownloadArchive, defaultGridFsArchiveFileName, formatGridFsUploadDate } from "@/lib/document/gridfsFiles";
import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/document/gridfsPagination";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
const props = defineProps<{
connectionId: string;
@ -25,22 +29,38 @@ const props = defineProps<{
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const loading = ref(false);
const uploading = ref(false);
const downloading = ref(false);
const deleting = ref(false);
const error = ref("");
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
const selectedFileId = ref("");
const checkedFileIds = ref<Set<string>>(new Set());
const showDeleteConfirm = ref(false);
const filterInput = ref("");
const sortInput = ref("");
const filterBuilderOpen = ref(false);
const filterRules = ref<DocumentFilterRule[]>([createGridFsFileFilterRule(uuid())]);
const appliedStructuredFilter = ref<Record<string, unknown> | null>(null);
const page = ref(0);
const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSize));
const totalBytes = computed(() => files.value.reduce((sum, file) => sum + (file.length || 0), 0));
const totalFileCount = computed(() => files.value.length);
const totalPages = computed(() => gridFsTotalPages(totalFileCount.value, pageSize.value));
const pagedFiles = computed(() => paginateGridFsItems(files.value, page.value, pageSize.value));
const pageSizeOptions = computed(() => resultPageSizeMenuOptions(pageSize.value));
const selectedFile = computed(() => files.value.find((file) => file.id === selectedFileId.value) || null);
const checkedFiles = computed(() => files.value.filter((file) => checkedFileIds.value.has(file.id)));
const checkedFileCount = computed(() => checkedFiles.value.length);
const canToggleAllFiles = computed(() => files.value.length > 0);
const allFilesChecked = computed(() => files.value.length > 0 && checkedFileIds.value.size === files.value.length);
const partiallyChecked = computed(() => checkedFileIds.value.size > 0 && checkedFileIds.value.size < files.value.length);
const downloadTargets = computed(() => (checkedFileCount.value > 0 ? checkedFiles.value : selectedFile.value ? [selectedFile.value] : []));
const downloadButtonLabel = computed(() => (checkedFileCount.value > 0 ? t("gridfsBrowser.downloadSelected") : t("gridfsBrowser.downloadFile")));
const selectedMetadata = computed(() => (selectedFile.value?.metadata ? JSON.stringify(selectedFile.value.metadata, null, 2) : ""));
const isReadonly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only ?? false);
const mongoProvider = documentStoreProviderFor("mongodb");
@ -85,6 +105,10 @@ function gridFsFileFieldLabel(fieldName: string): string {
return fieldName;
}
function formattedUploadDate(value?: string | null): string {
return formatGridFsUploadDate(value);
}
function currentFilesFilter(): string | undefined {
return currentDocumentFilterJson(filterInput.value, appliedStructuredFilter.value, "mongodb");
}
@ -121,15 +145,57 @@ function resetFilterBuilder() {
filterRules.value = [createGridFsFileFilterRule(uuid())];
}
function isFileChecked(fileId: string): boolean {
return checkedFileIds.value.has(fileId);
}
function setFileChecked(fileId: string, checked: boolean) {
const next = new Set(checkedFileIds.value);
if (checked) next.add(fileId);
else next.delete(fileId);
checkedFileIds.value = next;
}
function toggleAllFiles(checked: boolean) {
checkedFileIds.value = checked ? new Set(files.value.map((file) => file.id)) : new Set();
}
function ensurePageInRange() {
page.value = clampGridFsPage(page.value, totalFileCount.value, pageSize.value);
}
function resetToFirstPage() {
page.value = 0;
}
function prevPage() {
if (page.value <= 0) return;
page.value -= 1;
}
function nextPage() {
if (page.value + 1 >= totalPages.value) return;
page.value += 1;
}
function updatePageSize(nextValue: unknown) {
const normalized = normalizeResultPageSize(nextValue, pageSize.value);
pageSize.value = normalized;
resetToFirstPage();
settingsStore.updateEditorSettings({ pageSize: normalized });
}
async function loadFiles() {
loading.value = true;
error.value = "";
try {
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket, currentFilesFilter(), currentDocumentSortJson(sortInput.value));
files.value = nextFiles;
checkedFileIds.value = new Set(nextFiles.filter((file) => checkedFileIds.value.has(file.id)).map((file) => file.id));
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
selectedFileId.value = "";
}
ensurePageInRange();
} catch (e: any) {
error.value = e?.message || String(e);
} finally {
@ -138,6 +204,7 @@ async function loadFiles() {
}
function applyQuery() {
resetToFirstPage();
void loadFiles();
}
@ -152,6 +219,7 @@ function toggleSortForColumn(column: string) {
const current = currentSortDirection(column);
const nextDirection: SortDirection = current === "asc" ? "desc" : current === "desc" ? null : "asc";
sortInput.value = mongoProvider.sortInputForColumn(column, nextDirection);
resetToFirstPage();
void loadFiles();
}
@ -167,6 +235,7 @@ function sortIconClass(column: string): string {
function applyStructuredFilters() {
appliedStructuredFilter.value = buildGridFsFilesStructuredFilter(filterRules.value);
filterBuilderOpen.value = false;
resetToFirstPage();
void loadFiles();
}
@ -174,23 +243,64 @@ function clearAllFilters() {
filterInput.value = "";
appliedStructuredFilter.value = null;
filterRules.value = [createGridFsFileFilterRule(uuid())];
resetToFirstPage();
void loadFiles();
}
async function downloadFile(file: (typeof files.value)[number]) {
async function downloadSingleFile(file: (typeof files.value)[number]) {
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
const payload: BinaryCellDownloadPayload = {
data: bytes,
mimeType: file.contentType || "application/octet-stream",
extension: file.filename?.includes(".") ? file.filename.split(".").pop() || "bin" : "bin",
};
return downloadBinaryCellPayload(payload, displayName(file));
}
async function downloadMultipleFiles(selectedFiles: typeof files.value) {
const archiveEntries: Array<{ id: string; filename?: string; data: Uint8Array }> = [];
for (const file of selectedFiles) {
archiveEntries.push({
id: file.id,
filename: file.filename,
data: await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id),
});
}
const payload: BinaryCellDownloadPayload = {
data: buildGridFsDownloadArchive(archiveEntries),
mimeType: "application/zip",
extension: "zip",
};
return downloadBinaryCellPayload(payload, defaultGridFsArchiveFileName(props.bucket));
}
async function downloadSpecificFile(file: (typeof files.value)[number]) {
if (downloading.value) return;
downloading.value = true;
try {
const bytes = await api.documentDownloadGridFsFile(props.connectionId, props.database, props.bucket, file.id);
const payload: BinaryCellDownloadPayload = {
data: bytes,
mimeType: file.contentType || "application/octet-stream",
extension: file.filename?.includes(".") ? file.filename.split(".").pop() || "bin" : "bin",
};
const result = await downloadBinaryCellPayload(payload, displayName(file));
const result = await downloadSingleFile(file);
if (result.kind === "saved") {
toast(t("grid.exported"), 2500);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
downloading.value = false;
}
}
async function downloadCurrentSelection() {
if (downloading.value || downloadTargets.value.length === 0) return;
downloading.value = true;
try {
const result = downloadTargets.value.length === 1 ? await downloadSingleFile(downloadTargets.value[0]) : await downloadMultipleFiles(downloadTargets.value);
if (result.kind === "saved") {
toast(t("grid.exported"), 2500);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
downloading.value = false;
}
}
@ -250,6 +360,7 @@ async function deleteSelectedFile() {
await api.documentDeleteGridFsFile(props.connectionId, props.database, props.bucket, file.id);
showDeleteConfirm.value = false;
selectedFileId.value = "";
setFileChecked(file.id, false);
await loadFiles();
toast(t("gridfsBrowser.fileDeleted", { fileName: displayName(file) }), 2500);
} catch (e: any) {
@ -262,24 +373,53 @@ async function deleteSelectedFile() {
onMounted(() => {
void loadFiles();
});
watch(
() => settingsStore.editorSettings.pageSize,
(value) => {
pageSize.value = normalizeResultPageSize(value, pageSize.value);
ensurePageInRange();
},
);
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="border-b border-border px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold">{{ database }}.{{ bucket }}</div>
<div class="text-xs text-muted-foreground">{{ files.length }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}</div>
<div class="text-xs text-muted-foreground">
{{ totalFileCount }} {{ t("gridfsBrowser.fileCount") }} / {{ formatBytes(totalBytes) }}
<span v-if="checkedFileCount > 0"> / {{ t("gridfsBrowser.selectedCount", { count: checkedFileCount }) }}</span>
</div>
</div>
<div v-if="totalFileCount > 0" class="flex items-center gap-1 text-xs text-muted-foreground">
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="page <= 0" @click="prevPage">
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
<span class="tabular-nums">{{ page + 1 }} / {{ totalPages }}</span>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="page + 1 >= totalPages" @click="nextPage">
<ChevronRight class="h-3.5 w-3.5" />
</Button>
<Select :model-value="String(pageSize)" @update:model-value="(value) => updatePageSize(value)">
<SelectTrigger class="h-7 w-[106px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem v-for="size in pageSizeOptions" :key="size" :value="String(size)"> {{ size }}{{ t("grid.rowsPerPageShort") }} </SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" class="h-8 gap-1.5" :disabled="isReadonly || uploading" @click="uploadFile">
<Upload class="h-3.5 w-3.5" />
{{ t("gridfsBrowser.uploadFile") }}
</Button>
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="!selectedFile" @click="selectedFile && downloadFile(selectedFile)">
<Download class="h-3.5 w-3.5" />
{{ t("gridfsBrowser.downloadFile") }}
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="downloadTargets.length === 0 || downloading" @click="downloadCurrentSelection">
<LoaderCircle v-if="downloading" class="h-3.5 w-3.5 animate-spin" />
<Download v-else class="h-3.5 w-3.5" />
{{ downloadButtonLabel }}
</Button>
<Button variant="destructive" size="sm" class="h-8 gap-1.5" :disabled="isReadonly || !selectedFile || deleting" @click="showDeleteConfirm = true">
<Trash2 class="h-3.5 w-3.5" />
@ -442,49 +582,52 @@ onMounted(() => {
{{ t("gridfsBrowser.emptyFiles") }}
</div>
<div v-else class="min-h-0 flex-1 flex-col xl:flex-row xl:divide-x xl:divide-border">
<div v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row xl:divide-x xl:divide-border">
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full border-collapse text-sm">
<table class="min-w-full border-collapse text-[13px]">
<thead class="sticky top-0 z-10 bg-background">
<tr class="border-b border-border text-left text-xs text-muted-foreground">
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('filename')">
<th class="w-11 px-3 py-2 font-medium">
<input type="checkbox" class="h-3.5 w-3.5 accent-primary" :checked="allFilesChecked" :indeterminate="partiallyChecked" :disabled="!canToggleAllFiles" @click.stop @change="toggleAllFiles(($event.target as HTMLInputElement).checked)" />
</th>
<th class="min-w-[320px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('filename')">
<span>{{ gridFsFileFieldLabel("filename") }}</span>
<component :is="sortIconForColumn('filename')" class="h-3.5 w-3.5" :class="sortIconClass('filename')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('_id')">
<th class="min-w-[180px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('_id')">
<span>{{ gridFsFileFieldLabel("_id") }}</span>
<component :is="sortIconForColumn('_id')" class="h-3.5 w-3.5" :class="sortIconClass('_id')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('length')">
<th class="min-w-[96px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('length')">
<span>{{ gridFsFileFieldLabel("length") }}</span>
<component :is="sortIconForColumn('length')" class="h-3.5 w-3.5" :class="sortIconClass('length')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('chunkSize')">
<th class="min-w-[96px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('chunkSize')">
<span>{{ gridFsFileFieldLabel("chunkSize") }}</span>
<component :is="sortIconForColumn('chunkSize')" class="h-3.5 w-3.5" :class="sortIconClass('chunkSize')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('uploadDate')">
<th class="min-w-[158px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('uploadDate')">
<span>{{ gridFsFileFieldLabel("uploadDate") }}</span>
<component :is="sortIconForColumn('uploadDate')" class="h-3.5 w-3.5" :class="sortIconClass('uploadDate')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('contentType')">
<th class="min-w-[104px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('contentType')">
<span>{{ gridFsFileFieldLabel("contentType") }}</span>
<component :is="sortIconForColumn('contentType')" class="h-3.5 w-3.5" :class="sortIconClass('contentType')" />
</button>
</th>
<th class="px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 hover:text-foreground" @click="toggleSortForColumn('md5')">
<th class="min-w-[220px] px-4 py-2 font-medium">
<button type="button" class="inline-flex items-center gap-1 whitespace-nowrap hover:text-foreground" @click="toggleSortForColumn('md5')">
<span>{{ gridFsFileFieldLabel("md5") }}</span>
<component :is="sortIconForColumn('md5')" class="h-3.5 w-3.5" :class="sortIconClass('md5')" />
</button>
@ -492,23 +635,36 @@ onMounted(() => {
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.id" class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40" :class="{ 'bg-accent/45': selectedFileId === file.id }" @click="selectedFileId = file.id" @dblclick="downloadFile(file)">
<td class="px-4 py-2 align-top">
<div class="font-medium">{{ displayName(file) }}</div>
<div v-if="file.aliases?.length" class="mt-1 text-xs text-muted-foreground">{{ file.aliases.join(", ") }}</div>
<tr
v-for="file in pagedFiles"
:key="file.id"
class="cursor-pointer border-b border-border/60 transition-colors hover:bg-muted/40"
:class="{
'bg-accent/45': selectedFileId === file.id,
'bg-muted/35': selectedFileId !== file.id && isFileChecked(file.id),
}"
@click="selectedFileId = file.id"
@dblclick="downloadSpecificFile(file)"
>
<td class="px-3 py-2 align-top">
<input type="checkbox" class="h-3.5 w-3.5 accent-primary" :checked="isFileChecked(file.id)" @click.stop @change="setFileChecked(file.id, ($event.target as HTMLInputElement).checked)" />
</td>
<td class="px-4 py-2 align-top text-xs text-muted-foreground">{{ file.id }}</td>
<td class="px-4 py-2 align-top text-muted-foreground">{{ formatBytes(file.length) }}</td>
<td class="px-4 py-2 align-top text-muted-foreground">{{ formatBytes(file.chunkSize || 0) }}</td>
<td class="px-4 py-2 align-top text-muted-foreground">{{ file.uploadDate || "-" }}</td>
<td class="px-4 py-2 align-top text-muted-foreground">{{ file.contentType || "-" }}</td>
<td class="px-4 py-2 align-top text-xs text-muted-foreground">{{ file.md5 || "-" }}</td>
<td class="max-w-[440px] px-4 py-2 align-top">
<div class="truncate font-medium" :title="displayName(file)">{{ displayName(file) }}</div>
<div v-if="file.aliases?.length" class="mt-1 truncate text-xs text-muted-foreground" :title="file.aliases.join(', ')">{{ file.aliases.join(", ") }}</div>
</td>
<td class="px-4 py-2 align-top whitespace-nowrap text-[12px] text-muted-foreground">{{ file.id }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formatBytes(file.length) }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formatBytes(file.chunkSize || 0) }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap tabular-nums text-muted-foreground">{{ formattedUploadDate(file.uploadDate) }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap text-muted-foreground">{{ file.contentType || "-" }}</td>
<td class="px-4 py-2 align-top whitespace-nowrap text-[12px] text-muted-foreground">{{ file.md5 || "-" }}</td>
</tr>
</tbody>
</table>
</div>
<aside class="border-t border-border px-4 py-4 xl:w-80 xl:shrink-0 xl:border-t-0">
<aside class="overflow-auto border-t border-border px-4 py-4 xl:w-80 xl:shrink-0 xl:border-t-0">
<template v-if="selectedFile">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ t("tabs.gridfs") }}</div>
<div class="mt-2 break-all text-lg font-semibold">{{ displayName(selectedFile) }}</div>
@ -525,7 +681,7 @@ onMounted(() => {
</div>
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.uploadDate") }}</div>
<div class="mt-1 font-medium">{{ selectedFile.uploadDate || "-" }}</div>
<div class="mt-1 font-medium">{{ formattedUploadDate(selectedFile.uploadDate) }}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">{{ t("gridfsBrowser.contentType") }}</div>

View File

@ -152,7 +152,7 @@ onMounted(() => {
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="border-b border-border px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="min-w-0">
@ -263,7 +263,7 @@ onMounted(() => {
{{ t("gridfsBrowser.emptyBuckets") }}
</div>
<div v-else class="min-h-0 flex-1 flex-col xl:flex-row xl:divide-x xl:divide-border">
<div v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row xl:divide-x xl:divide-border">
<div class="min-h-0 flex-1 overflow-auto">
<table class="min-w-full border-collapse text-sm">
<thead class="sticky top-0 z-10 bg-background">
@ -298,7 +298,7 @@ onMounted(() => {
</table>
</div>
<aside class="border-t border-border px-4 py-4 xl:w-72 xl:shrink-0 xl:border-t-0">
<aside class="overflow-auto border-t border-border px-4 py-4 xl:w-72 xl:shrink-0 xl:border-t-0">
<template v-if="selectedBucket">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ t("tabs.gridfs") }}</div>
<div class="mt-2 break-all text-lg font-semibold">{{ selectedBucket.name }}</div>

View File

@ -612,7 +612,9 @@ export default {
bucketDeleted: "Bucket {bucket} deleted.",
uploadFile: "Upload File",
downloadFile: "Download File",
downloadSelected: "Download Selected",
deleteFile: "Delete File",
selectedCount: "{count} selected",
deleteFileTitle: "Delete GridFS File",
deleteFileMessage: "Delete the selected file from this GridFS bucket?",
fileUploaded: "Uploaded {fileName}.",

View File

@ -3391,7 +3391,9 @@ export default withEnglishFallback({
bucketDeleted: "Bucket {bucket} eliminado.",
uploadFile: "Subir archivo",
downloadFile: "Descargar archivo",
downloadSelected: "Descargar seleccionados",
deleteFile: "Eliminar archivo",
selectedCount: "{count} seleccionados",
deleteFileTitle: "Eliminar archivo de GridFS",
deleteFileMessage: "¿Estás seguro de eliminar el archivo seleccionado del bucket de GridFS actual?",
fileUploaded: "Archivo {fileName} subido.",

View File

@ -3389,7 +3389,9 @@ export default withEnglishFallback({
bucketDeleted: "Bucket {bucket} eliminato.",
uploadFile: "Carica file",
downloadFile: "Scarica file",
downloadSelected: "Scarica selezionati",
deleteFile: "Elimina file",
selectedCount: "{count} selezionati",
deleteFileTitle: "Elimina file GridFS",
deleteFileMessage: "Sei sicuro di voler eliminare il file selezionato dal bucket GridFS corrente?",
fileUploaded: "{fileName} caricato.",

View File

@ -3389,7 +3389,9 @@ export default withEnglishFallback({
bucketDeleted: "バケット {bucket} を削除しました。",
uploadFile: "ファイルをアップロード",
downloadFile: "ファイルをダウンロード",
downloadSelected: "選択項目をダウンロード",
deleteFile: "ファイルを削除",
selectedCount: "{count} 件選択",
deleteFileTitle: "GridFS ファイルの削除",
deleteFileMessage: "現在の GridFS バケットから選択したファイルを削除してもよろしいですか?",
fileUploaded: "{fileName} をアップロードしました。",

View File

@ -3390,7 +3390,9 @@ export default withEnglishFallback({
bucketDeleted: "Bucket {bucket} excluído.",
uploadFile: "Fazer upload de arquivo",
downloadFile: "Baixar arquivo",
downloadSelected: "Baixar selecionados",
deleteFile: "Excluir arquivo",
selectedCount: "{count} selecionados",
deleteFileTitle: "Excluir arquivo GridFS",
deleteFileMessage: "Tem certeza de que deseja excluir o arquivo selecionado do bucket GridFS atual?",
fileUploaded: "Arquivo {fileName} enviado.",

View File

@ -614,7 +614,9 @@ export default withEnglishFallback({
bucketDeleted: "\u5df2\u5220\u9664\u5b58\u50a8\u6876 {bucket}\u3002",
uploadFile: "\u4e0a\u4f20\u6587\u4ef6",
downloadFile: "\u4e0b\u8f7d\u6587\u4ef6",
downloadSelected: "\u4e0b\u8f7d\u9009\u4e2d",
deleteFile: "\u5220\u9664\u6587\u4ef6",
selectedCount: "\u5df2\u9009 {count} \u4e2a",
deleteFileTitle: "\u5220\u9664 GridFS \u6587\u4ef6",
deleteFileMessage: "\u786e\u5b9a\u4ece\u5f53\u524d GridFS \u5b58\u50a8\u6876\u4e2d\u5220\u9664\u9009\u4e2d\u6587\u4ef6\u5417\uff1f",
fileUploaded: "\u5df2\u4e0a\u4f20 {fileName}\u3002",

View File

@ -3390,7 +3390,9 @@ export default withEnglishFallback({
bucketDeleted: "已刪除儲存桶 {bucket}。",
uploadFile: "上傳檔案",
downloadFile: "下載檔案",
downloadSelected: "下載選取",
deleteFile: "刪除檔案",
selectedCount: "已選 {count} 個",
deleteFileTitle: "刪除 GridFS 檔案",
deleteFileMessage: "確定從目前 GridFS 儲存桶中刪除選中檔案嗎?",
fileUploaded: "已上傳 {fileName}。",

View File

@ -0,0 +1,183 @@
import dayjs from "dayjs";
export interface GridFsDownloadArchiveEntry {
id: string;
filename?: string;
data: Uint8Array;
}
type ZipEntry = {
path: string;
data: Uint8Array;
crc: number;
localHeaderOffset: number;
};
const encoder = new TextEncoder();
const CRC_TABLE = buildCrcTable();
const GRIDFS_DATETIME_PATTERN = "YYYY-MM-DD HH:mm:ss";
export function formatGridFsUploadDate(value?: string | null): string {
const trimmed = value?.trim();
if (!trimmed) return "-";
const parsed = dayjs(trimmed);
return parsed.isValid() ? parsed.format(GRIDFS_DATETIME_PATTERN) : trimmed;
}
export function defaultGridFsArchiveFileName(bucket: string, now = new Date()): string {
const safeBucket = sanitizePathStem(bucket) || "gridfs-files";
return `${safeBucket}-gridfs-${dayjs(now).format("YYYYMMDD-HHmmss")}.zip`;
}
export function buildGridFsDownloadArchive(entries: readonly GridFsDownloadArchiveEntry[]): Uint8Array {
if (entries.length === 0) {
throw new Error("At least one GridFS file must be selected.");
}
const seen = new Set<string>();
const files = entries.map((entry) => ({
path: uniqueArchiveEntryName(entry, seen),
data: entry.data,
}));
return buildZipArchive(files);
}
function uniqueArchiveEntryName(entry: GridFsDownloadArchiveEntry, seen: Set<string>): string {
const baseName = normalizeArchiveEntryName(entry.filename, entry.id);
let candidate = baseName;
let suffix = 2;
while (seen.has(candidate.toLowerCase())) {
candidate = withDuplicateSuffix(baseName, suffix);
suffix += 1;
}
seen.add(candidate.toLowerCase());
return candidate;
}
function normalizeArchiveEntryName(filename: string | undefined, id: string): string {
const fallback = `${sanitizePathStem(id) || "gridfs-file"}.bin`;
const raw = filename?.trim();
if (!raw) return fallback;
const normalized = raw.replace(/[\\/]+/g, "-");
const dotIndex = normalized.lastIndexOf(".");
const hasExtension = dotIndex > 0 && dotIndex < normalized.length - 1;
const rawStem = hasExtension ? normalized.slice(0, dotIndex) : normalized;
const rawExtension = hasExtension ? normalized.slice(dotIndex + 1) : "";
const stem = sanitizePathStem(rawStem) || sanitizePathStem(id) || "gridfs-file";
const extension = sanitizeExtension(rawExtension);
const fileName = extension ? `${stem}.${extension}` : stem;
return fileName.slice(0, 180) || fallback;
}
function withDuplicateSuffix(fileName: string, index: number): string {
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex > 0 && dotIndex < fileName.length - 1) {
const extension = fileName.slice(dotIndex);
const stem = fileName.slice(0, dotIndex).slice(0, Math.max(1, 180 - extension.length - `${index}`.length - 1));
return `${stem}-${index}${extension}`;
}
const stem = fileName.slice(0, Math.max(1, 180 - `${index}`.length - 1));
return `${stem}-${index}`;
}
function sanitizePathStem(value: string): string {
return value
.trim()
.replace(/[<>:"/\\|?*\u0000-\u001f]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-.\s]+|[-.\s]+$/g, "")
.slice(0, 160);
}
function sanitizeExtension(value: string): string {
return value.replace(/[^A-Za-z0-9_-]+/g, "").slice(0, 16);
}
function buildCrcTable(): number[] {
const table: number[] = [];
for (let i = 0; i < 256; i++) {
let crc = i;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
table.push(crc >>> 0);
}
return table;
}
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function uint16(value: number): Uint8Array {
const bytes = new Uint8Array(2);
new DataView(bytes.buffer).setUint16(0, value, true);
return bytes;
}
function uint32(value: number): Uint8Array {
const bytes = new Uint8Array(4);
new DataView(bytes.buffer).setUint32(0, value >>> 0, true);
return bytes;
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const total = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function buildZipArchive(files: ReadonlyArray<{ path: string; data: Uint8Array }>): Uint8Array {
const entries: ZipEntry[] = [];
const localParts: Uint8Array[] = [];
let offset = 0;
for (const file of files) {
const path = encoder.encode(file.path);
const crc = crc32(file.data);
const localHeader = concatBytes([uint32(0x04034b50), uint16(20), uint16(0), uint16(0), uint16(0), uint16(0), uint32(crc), uint32(file.data.length), uint32(file.data.length), uint16(path.length), uint16(0), path]);
entries.push({ path: file.path, data: file.data, crc, localHeaderOffset: offset });
localParts.push(localHeader, file.data);
offset += localHeader.length + file.data.length;
}
const centralParts = entries.map((entry) => {
const path = encoder.encode(entry.path);
return concatBytes([
uint32(0x02014b50),
uint16(20),
uint16(20),
uint16(0),
uint16(0),
uint16(0),
uint16(0),
uint32(entry.crc),
uint32(entry.data.length),
uint32(entry.data.length),
uint16(path.length),
uint16(0),
uint16(0),
uint16(0),
uint16(0),
uint32(0),
uint32(entry.localHeaderOffset),
path,
]);
});
const centralDirectory = concatBytes(centralParts);
const endOfCentralDirectory = concatBytes([uint32(0x06054b50), uint16(0), uint16(0), uint16(entries.length), uint16(entries.length), uint32(centralDirectory.length), uint32(offset), uint16(0)]);
return concatBytes([...localParts, centralDirectory, endOfCentralDirectory]);
}

View File

@ -0,0 +1,20 @@
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
export function gridFsTotalPages(totalCount: number, pageSize: number): number {
const normalizedPageSize = Math.max(1, normalizeResultPageSize(pageSize));
const normalizedTotal = Number.isFinite(totalCount) ? Math.max(0, Math.floor(totalCount)) : 0;
return Math.max(1, Math.ceil(normalizedTotal / normalizedPageSize));
}
export function clampGridFsPage(page: number, totalCount: number, pageSize: number): number {
const lastPage = gridFsTotalPages(totalCount, pageSize) - 1;
if (!Number.isFinite(page)) return 0;
return Math.max(0, Math.min(Math.floor(page), lastPage));
}
export function paginateGridFsItems<T>(items: readonly T[], page: number, pageSize: number): T[] {
const normalizedPageSize = Math.max(1, normalizeResultPageSize(pageSize));
const safePage = clampGridFsPage(page, items.length, normalizedPageSize);
const start = safePage * normalizedPageSize;
return items.slice(start, start + normalizedPageSize);
}

View File

@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "../../apps/desktop/src/lib/document/gridfsPagination.ts";
test("paginates GridFS files with a clamped page index", () => {
const files = ["f1", "f2", "f3", "f4", "f5"].map((id) => ({ id }));
assert.equal(gridFsTotalPages(files.length, 2), 3);
assert.equal(clampGridFsPage(9, files.length, 2), 2);
assert.deepEqual(paginateGridFsItems(files, 1, 2).map((file) => file.id), ["f3", "f4"]);
assert.deepEqual(paginateGridFsItems(files, 9, 2).map((file) => file.id), ["f5"]);
});
test("GridFS browsers keep a scrollable flex layout", () => {
const bucketBrowser = readFileSync("apps/desktop/src/components/document/MongoBucketBrowser.vue", "utf8");
const gridfsBrowser = readFileSync("apps/desktop/src/components/document/MongoGridFsBrowser.vue", "utf8");
assert.match(bucketBrowser, /class="flex h-full min-h-0 flex-col overflow-hidden"/);
assert.match(bucketBrowser, /v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row/);
assert.match(gridfsBrowser, /class="flex h-full min-h-0 flex-col overflow-hidden"/);
assert.match(gridfsBrowser, /v-else class="min-h-0 flex flex-1 flex-col overflow-hidden xl:flex-row/);
});
test("GridFS file browser keeps metadata columns compact on a single line", () => {
const bucketBrowser = readFileSync("apps/desktop/src/components/document/MongoBucketBrowser.vue", "utf8");
assert.match(bucketBrowser, /<table class="min-w-full border-collapse text-\[13px\]">/);
assert.match(bucketBrowser, /whitespace-nowrap tabular-nums/);
});

View File

@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import dayjs from "dayjs";
import { test } from "vitest";
import { buildGridFsDownloadArchive, defaultGridFsArchiveFileName, formatGridFsUploadDate } from "../../apps/desktop/src/lib/document/gridfsFiles.ts";
test("formats GridFS upload timestamps as compact local datetimes", () => {
const raw = "2026-07-07T01:22:42.123Z";
assert.equal(formatGridFsUploadDate(raw), dayjs(raw).format("YYYY-MM-DD HH:mm:ss"));
});
test("keeps invalid GridFS upload timestamps readable", () => {
assert.equal(formatGridFsUploadDate("not-a-date"), "not-a-date");
assert.equal(formatGridFsUploadDate(""), "-");
assert.equal(formatGridFsUploadDate(undefined), "-");
});
test("builds a GridFS download zip with safe deduplicated file names", () => {
const archive = buildGridFsDownloadArchive([
{ id: "alpha", filename: "report.txt", data: new TextEncoder().encode("hello") },
{ id: "beta", filename: "report.txt", data: new TextEncoder().encode("world") },
{ id: "gamma", filename: "bad/name?.json", data: new TextEncoder().encode("{}") },
{ id: "delta", data: new TextEncoder().encode("fallback") },
]);
const text = new TextDecoder().decode(archive);
assert.equal(archive[0], 0x50);
assert.equal(archive[1], 0x4b);
assert.match(text, /report\.txt/);
assert.match(text, /report-2\.txt/);
assert.match(text, /bad-name\.json/);
assert.match(text, /delta\.bin/);
});
test("builds a timestamped GridFS archive file name from the bucket", () => {
const now = new Date("2026-07-07T01:22:42.000Z");
assert.equal(
defaultGridFsArchiveFileName("user uploads", now),
`user-uploads-gridfs-${dayjs(now).format("YYYYMMDD-HHmmss")}.zip`,
);
});