feat(grid): import BYTEA/BLOB cell from file with size gate
This commit is contained in:
parent
31587bcbfe
commit
35362c8bf4
|
|
@ -6,7 +6,8 @@ import {
|
|||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Upload,
|
||||
Download,
|
||||
FileUp,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
|
|
@ -122,12 +123,17 @@ import { getApplicablePreviewActions } from "@/lib/dataGrid/resultPreviewRegistr
|
|||
import "@/lib/dataGrid/geometryMapPreview";
|
||||
import {
|
||||
BINARY_CELL_DOWNLOAD_MODES,
|
||||
BinaryCellImportTooLargeError,
|
||||
binaryCellBytesToHexValue,
|
||||
binaryCellDisplayText,
|
||||
binaryCellDownloadFileName,
|
||||
binaryCellDownloadPayload,
|
||||
canImportBinaryCellFile,
|
||||
canDownloadBinaryCellValue,
|
||||
downloadBinaryCellPayload,
|
||||
formatBinaryCellByteSize,
|
||||
isBinaryCellColumnType,
|
||||
openBinaryCellFile,
|
||||
parseBinaryCellBytes,
|
||||
retainBinaryCellDownloadMenuForHover,
|
||||
type BinaryCellDownloadMode,
|
||||
|
|
@ -7063,6 +7069,39 @@ function canDownloadDetailBinaryValue(detail: DataGridCellDetail | null): boolea
|
|||
return !!detail && canDownloadBinaryCellValue(detail.value, detail.type);
|
||||
}
|
||||
|
||||
function canImportDetailBinaryValue(detail: DataGridCellDetail | null): boolean {
|
||||
return !!detail?.isEditable && canImportBinaryCellFile(resolvedDatabaseType.value, detail.type);
|
||||
}
|
||||
|
||||
async function importDetailBinaryValue(detail: DataGridCellDetail | null) {
|
||||
if (!detail || !canImportDetailBinaryValue(detail)) return;
|
||||
try {
|
||||
const bytes = await openBinaryCellFile();
|
||||
if (!bytes) return;
|
||||
const value = binaryCellBytesToHexValue(bytes);
|
||||
applyCellValue(detail.rowId, detail.colIndex, value);
|
||||
if (activeCellDetail.value?.rowId === detail.rowId && activeCellDetail.value.colIndex === detail.colIndex) {
|
||||
detailEditValue.value = value;
|
||||
detailEditOriginalValue.value = value;
|
||||
syncEditorFromDetailEdit();
|
||||
detailCell.value = detailCell.value ? { ...detailCell.value } : null;
|
||||
}
|
||||
toast(t("grid.binaryImportApplied", { count: bytes.length }));
|
||||
} catch (e: any) {
|
||||
if (e instanceof BinaryCellImportTooLargeError) {
|
||||
toast(
|
||||
t("grid.binaryImportTooLarge", {
|
||||
size: formatBinaryCellByteSize(e.bytes),
|
||||
limit: formatBinaryCellByteSize(e.limit),
|
||||
}),
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast(t("grid.binaryImportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function canQuickDownloadCellValue(rowIndex: number, columnIndex: number): boolean {
|
||||
return canDownloadDetailBinaryValue(cellDetailFor(rowIndex, columnIndex));
|
||||
}
|
||||
|
|
@ -7096,7 +7135,7 @@ function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuIt
|
|||
if (!canDownloadDetailBinaryValue(detail)) return null;
|
||||
return {
|
||||
label: t("grid.downloadBinaryValue"),
|
||||
icon: Upload,
|
||||
icon: Download,
|
||||
children: BINARY_CELL_DOWNLOAD_MODES.map((mode) => ({
|
||||
label: t(`grid.binaryDownload.${mode}`),
|
||||
action: () => {
|
||||
|
|
@ -7106,6 +7145,17 @@ function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuIt
|
|||
};
|
||||
}
|
||||
|
||||
function binaryImportItem(detail: DataGridCellDetail | null): ContextMenuItem | null {
|
||||
if (!canImportDetailBinaryValue(detail)) return null;
|
||||
return {
|
||||
label: t("grid.importBinaryValue"),
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
void importDetailBinaryValue(detail);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function copyDetailSqlCondition() {
|
||||
if (!canCopyPreparedDetailSqlCondition()) return;
|
||||
copyText(detailSqlConditionCopy.value.text);
|
||||
|
|
@ -8846,7 +8896,7 @@ function exportSubmenu(): ContextMenuItem {
|
|||
{ label: t("grid.exportSelectedRowsTxt"), action: exportSelectedRowsTxt },
|
||||
);
|
||||
}
|
||||
return { label: t("grid.export"), icon: Upload, children: items };
|
||||
return { label: t("grid.export"), icon: Download, children: items };
|
||||
}
|
||||
|
||||
const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
|
|
@ -8939,6 +8989,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
},
|
||||
icons: { cellDetails: Maximize2, columnDetails: TableProperties, rowDetails: ListTree, setNull: X, bulkEdit: Pencil, transpose: Rows3 },
|
||||
actions: { cellDetails: openContextCellDetailDialog, columnDetails: openContextColumnDetailDialog, rowDetails: openContextRowDetailDialog, setNull: setSelectionNull, bulkEdit: openBulkEditDialog, transpose: openContextTranspose },
|
||||
importItem: binaryImportItem(contextCellDetail.value),
|
||||
downloadItem: binaryDownloadSubmenu(contextCellDetail.value),
|
||||
foreignKeyItem: contextForeignKeyMenuItem(),
|
||||
copySubmenu: copySubmenu(),
|
||||
|
|
@ -9376,7 +9427,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
>
|
||||
<template #trigger="{ open, toggle }">
|
||||
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
|
||||
<Upload class="h-3 w-3" />
|
||||
<Download class="h-3 w-3" />
|
||||
</button>
|
||||
</template>
|
||||
</LightDropdownMenu>
|
||||
|
|
@ -9890,7 +9941,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
>
|
||||
<template #trigger="{ open, toggle }">
|
||||
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
|
||||
<Upload class="h-3 w-3" />
|
||||
<Download class="h-3 w-3" />
|
||||
</button>
|
||||
</template>
|
||||
</LightDropdownMenu>
|
||||
|
|
@ -10093,7 +10144,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
>
|
||||
<template #trigger="{ open, toggle }">
|
||||
<button class="flex h-5 w-5 items-center justify-center rounded bg-background/90 text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground" :title="t('grid.downloadBinaryValue')" :aria-expanded="open" @mousedown.stop @click.stop="toggle">
|
||||
<Upload class="h-3 w-3" />
|
||||
<Download class="h-3 w-3" />
|
||||
</button>
|
||||
</template>
|
||||
</LightDropdownMenu>
|
||||
|
|
@ -10408,6 +10459,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
:type-color-class="typeColorClass"
|
||||
:can-download-binary-value="canDownloadDetailBinaryValue"
|
||||
:download-binary-value="downloadDetailBinaryValue"
|
||||
:can-import-binary-value="canImportDetailBinaryValue"
|
||||
:import-binary-value="importDetailBinaryValue"
|
||||
:open-image-preview="openImagePreview"
|
||||
:can-copy-sql-condition="canCopyPreparedDetailSqlCondition"
|
||||
@start-edit="startDetailEdit"
|
||||
|
|
@ -10582,6 +10635,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
:copy-text="copyText"
|
||||
:can-download-binary-value="canDownloadDetailBinaryValue"
|
||||
:download-binary-value="downloadDetailBinaryValue"
|
||||
:can-import-binary-value="canImportDetailBinaryValue"
|
||||
:import-binary-value="importDetailBinaryValue"
|
||||
@edit="openDialogCellInSidePanel"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { Code2, Copy, Eye, Info, Pencil, Upload } from "@lucide/vue";
|
||||
import { Code2, Copy, Download, Eye, FileUp, Info, Pencil } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -25,6 +25,8 @@ const props = defineProps<{
|
|||
copyText: (text: string) => void;
|
||||
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
|
||||
canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -152,10 +154,13 @@ watch(
|
|||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.copyValue')" @click="copyCurrentValue">
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button v-if="canImportBinaryValue(detail)" variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.importBinaryValue')" @click="importBinaryValue(detail)">
|
||||
<FileUp class="h-3 w-3" />
|
||||
</Button>
|
||||
<DropdownMenu v-if="canDownloadBinaryValue(detail)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.downloadBinaryValue')">
|
||||
<Upload class="h-3 w-3" />
|
||||
<Download class="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-44">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { toRef } from "vue";
|
||||
import { Code2, Copy, Eye, Pencil, Upload, X } from "@lucide/vue";
|
||||
import { Code2, Copy, Download, Eye, FileUp, Pencil, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
|
|
@ -30,6 +30,8 @@ const props = defineProps<{
|
|||
typeColorClass: (type: string) => string;
|
||||
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
|
||||
canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
|
||||
openImagePreview: (src: string, title: string) => void;
|
||||
canCopySqlCondition: () => boolean;
|
||||
}>();
|
||||
|
|
@ -122,9 +124,10 @@ defineExpose({ openSearch });
|
|||
<Button v-if="!editing && detail.formattedJson" :variant="sideJsonView ? 'secondary' : 'ghost'" size="sm" class="h-5 gap-1 px-1.5 text-xs" :title="t('grid.formattedJson')" @click="emit('toggleFormatted')"><Code2 class="h-3 w-3" />{{ t("grid.formattedJson") }}</Button>
|
||||
<Button v-if="!editing && detail.isEditable" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.editValue')" @click="emit('startEdit')"><Pencil class="h-3 w-3" /></Button>
|
||||
<Button v-if="!editing" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.copyValue')" @click="emit('copyValue')"><Copy class="h-3 w-3" /></Button>
|
||||
<Button v-if="canImportBinaryValue(detail)" variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.importBinaryValue')" @click="importBinaryValue(detail)"><FileUp class="h-3 w-3" /></Button>
|
||||
<DropdownMenu v-if="!editing && canDownloadBinaryValue(detail)"
|
||||
><DropdownMenuTrigger as-child
|
||||
><Button variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.downloadBinaryValue')"><Upload class="h-3 w-3" /></Button></DropdownMenuTrigger
|
||||
><Button variant="ghost" size="icon" class="h-5 w-5" :title="t('grid.downloadBinaryValue')"><Download class="h-3 w-3" /></Button></DropdownMenuTrigger
|
||||
><DropdownMenuContent align="end" class="w-44"
|
||||
><DropdownMenuItem v-for="mode in BINARY_CELL_DOWNLOAD_MODES" :key="mode" @click="downloadBinaryValue(detail, mode)">{{ t(`grid.binaryDownload.${mode}`) }}</DropdownMenuItem></DropdownMenuContent
|
||||
></DropdownMenu
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { Upload } from "@lucide/vue";
|
||||
import { Download } from "@lucide/vue";
|
||||
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
|
||||
|
||||
defineProps<{
|
||||
|
|
@ -14,7 +14,7 @@ defineProps<{
|
|||
model-value=""
|
||||
:items="items"
|
||||
:aria-label="label"
|
||||
:trigger-icon="Upload"
|
||||
:trigger-icon="Download"
|
||||
:trigger-label="label"
|
||||
trigger-class="inline-flex h-6 shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 text-foreground/80 hover:bg-accent hover:text-accent-foreground"
|
||||
trigger-icon-class="h-3.5 w-3.5"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Check, ChevronDown, Copy, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2, Upload } from "@lucide/vue";
|
||||
import { Check, ChevronDown, Copy, Download, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -164,7 +164,7 @@ function actionLabelClass() {
|
|||
<TooltipTrigger as-child>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="exportData?.disabled || !exportData?.items.length">
|
||||
<Upload class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<Download class="data-grid-topbar-action-icon h-3 w-3" />
|
||||
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ exportData?.label }}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ vi.mock("@lucide/vue", async () => {
|
|||
ChevronRight: icon,
|
||||
ChevronsLeft: icon,
|
||||
ChevronsRight: icon,
|
||||
Download: icon,
|
||||
Filter: icon,
|
||||
Loader2: icon,
|
||||
FileUp: icon,
|
||||
Upload: icon,
|
||||
Search: icon,
|
||||
X: icon,
|
||||
|
|
@ -873,7 +875,20 @@ describe("cell detail surfaces", () => {
|
|||
const copyText = vi.fn();
|
||||
const edit = vi.fn();
|
||||
const updateOpen = vi.fn();
|
||||
const mounted = mountComponent(DataGridCellDetailDialog, { open: true, detail: detail(), typeColorClass: () => "", openImagePreview: vi.fn(), copyText, canDownloadBinaryValue: () => false, downloadBinaryValue: vi.fn(), onEdit: edit, "onUpdate:open": updateOpen });
|
||||
const importBinaryValue = vi.fn();
|
||||
const mounted = mountComponent(DataGridCellDetailDialog, {
|
||||
open: true,
|
||||
detail: detail({ type: "BYTEA", isEditable: true }),
|
||||
typeColorClass: () => "",
|
||||
openImagePreview: vi.fn(),
|
||||
copyText,
|
||||
canDownloadBinaryValue: () => false,
|
||||
downloadBinaryValue: vi.fn(),
|
||||
canImportBinaryValue: () => true,
|
||||
importBinaryValue,
|
||||
onEdit: edit,
|
||||
"onUpdate:open": updateOpen,
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
|
|
@ -885,6 +900,11 @@ describe("cell detail surfaces", () => {
|
|||
"click",
|
||||
);
|
||||
expect(edit).toHaveBeenCalledOnce();
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.props.title === "grid.importBinaryValue"),
|
||||
"click",
|
||||
);
|
||||
expect(importBinaryValue).toHaveBeenCalledOnce();
|
||||
|
||||
await mounted.setProps({ detail: detail({ rawValue: '{"b":2}', formattedJson: '{\n "b": 2\n}' }) });
|
||||
expect(mocks.editor.setValue).toHaveBeenCalledWith('{\n "b": 2\n}', "json");
|
||||
|
|
@ -912,6 +932,8 @@ describe("cell detail surfaces", () => {
|
|||
typeColorClass: () => "",
|
||||
canDownloadBinaryValue: () => false,
|
||||
downloadBinaryValue: vi.fn(),
|
||||
canImportBinaryValue: () => false,
|
||||
importBinaryValue: vi.fn(),
|
||||
openImagePreview: vi.fn(),
|
||||
canCopySqlCondition: () => true,
|
||||
onStartEdit: startEdit,
|
||||
|
|
|
|||
|
|
@ -1649,6 +1649,10 @@ export default {
|
|||
rawValue: "Raw Value",
|
||||
largeValuePreviewHint: "Previewing first {count} characters. Copy still uses the full value.",
|
||||
copyValue: "Copy Value",
|
||||
importBinaryValue: "Import from File",
|
||||
binaryImportApplied: "Imported {count} bytes. Save changes to write them to the database.",
|
||||
binaryImportFailed: "Failed to import file: {message}",
|
||||
binaryImportTooLarge: "File is too large ({size}, limit {limit}). Importing large files into a single cell is not supported.",
|
||||
downloadBinaryValue: "Download Value",
|
||||
downloadSaved: "Saved to {path}",
|
||||
downloadStarted: "Download started: {fileName}",
|
||||
|
|
|
|||
|
|
@ -1675,6 +1675,10 @@ export default withEnglishFallback({
|
|||
xlsxHeaderOriginal: "Encabezado usando nombres de campos",
|
||||
xlsxHeaderComment: "Encabezado usando comentarios",
|
||||
jumpToPage: "Ir a la página",
|
||||
importBinaryValue: "Importar desde archivo",
|
||||
binaryImportApplied: "Se importaron {count} bytes, que se escribirán en la base de datos al guardar los cambios.",
|
||||
binaryImportFailed: "Error al importar el archivo: {message}",
|
||||
binaryImportTooLarge: "El archivo es demasiado grande ({size}, límite {limit}). No se admite importar archivos grandes en una sola celda.",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "La exportación en streaming no es compatible con esta consulta. Simplifíquela o use un controlador compatible.",
|
||||
|
|
|
|||
|
|
@ -1673,6 +1673,10 @@ export default withEnglishFallback({
|
|||
xlsxHeaderOriginal: "Intestazione con nome campo",
|
||||
xlsxHeaderComment: "Intestazione con commento",
|
||||
jumpToPage: "Vai alla pagina",
|
||||
importBinaryValue: "Importa da file",
|
||||
binaryImportApplied: "Importati {count} byte, scritti nel database dopo il salvataggio delle modifiche.",
|
||||
binaryImportFailed: "Importazione del file non riuscita: {message}",
|
||||
binaryImportTooLarge: "Il file è troppo grande ({size}, limite {limit}). L'importazione di file di grandi dimensioni in una singola cella non è supportata.",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.",
|
||||
|
|
|
|||
|
|
@ -1700,6 +1700,10 @@ export default withEnglishFallback({
|
|||
searchModeHighlight: "ハイライト",
|
||||
emptyStringValue: "空文字",
|
||||
jumpToPage: "ページ番号に移動",
|
||||
importBinaryValue: "ファイルからインポート",
|
||||
binaryImportApplied: "{count} バイトをインポートしました。変更を保存するとデータベースに書き込まれます。",
|
||||
binaryImportFailed: "ファイルのインポートに失敗しました:{message}",
|
||||
binaryImportTooLarge: "ファイルが大きすぎます({size}、上限 {limit})。大きなファイルの単一セルへのインポートはサポートされていません。",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
|
||||
|
|
|
|||
|
|
@ -1519,6 +1519,7 @@ export default withEnglishFallback({
|
|||
largeValuePreviewHint: "처음 {count}자 미리보는 중. 복사에는 여전히 전체 값이 사용됩니다.",
|
||||
copyValue: "값 복사",
|
||||
downloadBinaryValue: "값 다운로드",
|
||||
binaryImportTooLarge: "파일이 너무 큽니다({size}, 상한 {limit}). 큰 파일을 단일 셀에 가져오는 것은 지원되지 않습니다.",
|
||||
downloadSaved: "{path}에 저장됨",
|
||||
downloadStarted: "다운로드 시작됨: {fileName}",
|
||||
binaryDownload: {
|
||||
|
|
|
|||
|
|
@ -1675,6 +1675,10 @@ export default withEnglishFallback({
|
|||
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",
|
||||
xlsxHeaderComment: "Cabeçalho usa comentário",
|
||||
jumpToPage: "Ir para a página",
|
||||
importBinaryValue: "Importar do arquivo",
|
||||
binaryImportApplied: "{count} bytes importados, gravados no banco de dados após salvar as alterações.",
|
||||
binaryImportFailed: "Falha ao importar o arquivo: {message}",
|
||||
binaryImportTooLarge: "O arquivo é muito grande ({size}, limite {limit}). Não há suporte para importar arquivos grandes em uma única célula.",
|
||||
},
|
||||
exportProgress: {
|
||||
streamingUnsupported: "A exportação em streaming não é compatível com esta consulta. Simplifique-a ou use um driver compatível.",
|
||||
|
|
|
|||
|
|
@ -1649,6 +1649,10 @@ export default withEnglishFallback({
|
|||
rawValue: "原始值",
|
||||
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
|
||||
copyValue: "复制值",
|
||||
importBinaryValue: "从文件导入",
|
||||
binaryImportApplied: "已导入 {count} 字节,保存更改后写入数据库。",
|
||||
binaryImportFailed: "导入文件失败:{message}",
|
||||
binaryImportTooLarge: "文件过大({size},上限 {limit})。不支持将大文件导入到单个单元格。",
|
||||
downloadBinaryValue: "下载值",
|
||||
downloadSaved: "已保存到 {path}",
|
||||
downloadStarted: "已交给浏览器下载:{fileName}",
|
||||
|
|
|
|||
|
|
@ -1524,6 +1524,10 @@ export default withEnglishFallback({
|
|||
rawValue: "原始值",
|
||||
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
|
||||
copyValue: "複製值",
|
||||
importBinaryValue: "從檔案匯入",
|
||||
binaryImportApplied: "已匯入 {count} 位元組,儲存變更後寫入資料庫。",
|
||||
binaryImportFailed: "匯入檔案失敗:{message}",
|
||||
binaryImportTooLarge: "檔案過大({size},上限 {limit})。不支援將大檔案匯入到單一儲存格。",
|
||||
downloadBinaryValue: "下載值",
|
||||
downloadSaved: "已儲存到 {path}",
|
||||
downloadStarted: "已交給瀏覽器下載:{fileName}",
|
||||
|
|
|
|||
|
|
@ -63,9 +63,12 @@ describe("dataGridContextMenu", () => {
|
|||
labels: { cellDetails: "cell", columnDetails: "column", rowDetails: "row", setNull: "null", bulkEdit: "bulk", transpose: "transpose" },
|
||||
icons: { cellDetails: icon, columnDetails: icon, rowDetails: icon, setNull: icon, bulkEdit: icon, transpose: icon },
|
||||
actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action },
|
||||
importItem: { label: "import" },
|
||||
downloadItem: { label: "download" },
|
||||
copySubmenu: { label: "copy" },
|
||||
clearSelectionItem: { label: "clear" },
|
||||
});
|
||||
expect(cellItems.slice(0, 4).map((item) => item.label)).toEqual(["cell", "import", "download", "column"]);
|
||||
expect(cellItems.find((item) => item.label === "bulk")?.disabled).toBe(true);
|
||||
|
||||
const rowItems = createDataGridRowContextMenuItems({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { binaryCellBytesToHexValue, canImportBinaryCellFile } from "@/lib/dataGrid/binaryCellDownload";
|
||||
|
||||
describe("binary cell file import", () => {
|
||||
it("encodes arbitrary and empty files as prefixed hex cell values", () => {
|
||||
expect(binaryCellBytesToHexValue(new Uint8Array([0x00, 0x01, 0xab, 0xff]))).toBe("0x0001abff");
|
||||
expect(binaryCellBytesToHexValue(new Uint8Array())).toBe("0x");
|
||||
});
|
||||
|
||||
it("offers file import only where prefixed hex values have binary save syntax", () => {
|
||||
expect(canImportBinaryCellFile("postgres", "bytea")).toBe(true);
|
||||
expect(canImportBinaryCellFile("mysql", "longblob")).toBe(true);
|
||||
expect(canImportBinaryCellFile("mysql", "varbinary(255)")).toBe(true);
|
||||
expect(canImportBinaryCellFile("postgres", "text")).toBe(false);
|
||||
expect(canImportBinaryCellFile("sqlserver", "varbinary(max)")).toBe(false);
|
||||
expect(canImportBinaryCellFile("sqlite", "blob")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export type BinaryCellDownloadMode = "binary" | "utf8" | "gbk";
|
||||
|
||||
|
|
@ -22,6 +23,86 @@ export interface BinaryCellDownloadResult {
|
|||
|
||||
export const BINARY_CELL_DOWNLOAD_MODES: BinaryCellDownloadMode[] = ["binary", "utf8", "gbk"];
|
||||
|
||||
/**
|
||||
* 单个 BLOB/BYTEA 单元格从文件导入的最大字节数。
|
||||
*
|
||||
* 设立此上限的原因:导入路径会用 `@tauri-apps/plugin-fs` 的 `readFile(path)` 把整个文件
|
||||
* 一次性读进 `Uint8Array` 经 IPC 返回前端,再经 `binaryCellBytesToHexValue` 转成
|
||||
* `0x<hex>` 字符串(长度约为文件字节的 2 倍)作为脏值常驻内存,直到保存或放弃编辑。
|
||||
* 几百 MB 的 BLOB 即可造成 OOM/卡死,故在进入 readFile + hex 转换路径之前先按文件大小拦截。
|
||||
* 16 MB 对单个单元格导入已相当宽裕,分块流式 + 后端参数绑定属后续更大改造,不在此处处理。
|
||||
*/
|
||||
export const MAX_BINARY_CELL_IMPORT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 文件超过 {@link MAX_BINARY_CELL_IMPORT_BYTES} 时抛出的错误。
|
||||
* 调用方据 `code === "binary-import-too-large"` 选择专门的提示文案,
|
||||
* 其余失败仍走通用错误提示。
|
||||
*/
|
||||
export class BinaryCellImportTooLargeError extends Error {
|
||||
readonly code = "binary-import-too-large" as const;
|
||||
readonly bytes: number;
|
||||
readonly limit: number;
|
||||
constructor(bytes: number, limit: number) {
|
||||
super(`File is ${bytes} bytes, exceeds the ${limit}-byte import limit.`);
|
||||
this.name = "BinaryCellImportTooLargeError";
|
||||
this.bytes = bytes;
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
export function binaryCellBytesToHexValue(bytes: Uint8Array): string {
|
||||
let hex = "0x";
|
||||
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
||||
return hex;
|
||||
}
|
||||
|
||||
function openBinaryCellFileInBrowser(): Promise<Uint8Array | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.onchange = async () => {
|
||||
try {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// 尺寸闸门:File.size 在读取前即可用,避免大文件进入 arrayBuffer + hex 路径。
|
||||
if (file.size > MAX_BINARY_CELL_IMPORT_BYTES) {
|
||||
reject(new BinaryCellImportTooLargeError(file.size, MAX_BINARY_CELL_IMPORT_BYTES));
|
||||
return;
|
||||
}
|
||||
resolve(new Uint8Array(await file.arrayBuffer()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
export async function openBinaryCellFile(): Promise<Uint8Array | undefined> {
|
||||
if (!isTauriRuntime()) return openBinaryCellFileInBrowser();
|
||||
|
||||
const [{ open }, { readFile, stat }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const selected = await open({ multiple: false });
|
||||
const path = Array.isArray(selected) ? selected[0] : selected;
|
||||
if (!path) return undefined;
|
||||
// 尺寸闸门:在 readFile 之前用 stat 取文件大小,避免大文件被全量读入内存。
|
||||
// stat 不可用(权限/平台差异等)时降级为不阻断,保留原 readFile 行为,避免功能完全不可用。
|
||||
try {
|
||||
const info = await stat(path);
|
||||
if (info.size > MAX_BINARY_CELL_IMPORT_BYTES) {
|
||||
throw new BinaryCellImportTooLargeError(info.size, MAX_BINARY_CELL_IMPORT_BYTES);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BinaryCellImportTooLargeError) throw error;
|
||||
console.warn("[binaryCellDownload] stat failed, skipping size gate:", error);
|
||||
}
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
export function retainBinaryCellDownloadMenuForHover(openCell: BinaryCellPosition | null, hoveredCell: BinaryCellPosition): BinaryCellPosition | null {
|
||||
return openCell?.rowIndex === hoveredCell.rowIndex && openCell.col === hoveredCell.col ? openCell : null;
|
||||
}
|
||||
|
|
@ -30,6 +111,7 @@ const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]+)$/;
|
|||
const BARE_HEX_RE = /^[0-9a-fA-F\s]+$/;
|
||||
const HEX_ESCAPE_RE = /^(?:\\x[0-9a-fA-F]{2}|\s)+$/;
|
||||
const BINARY_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i;
|
||||
const MYSQL_FILE_IMPORT_TYPE_RE = /^(?:blob|tinyblob|mediumblob|longblob|binary|varbinary)(?:\b|\()/i;
|
||||
|
||||
function copyBytesForBlob(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
return new Uint8Array(bytes);
|
||||
|
|
@ -96,6 +178,13 @@ export function isBinaryCellColumnType(columnType?: string): boolean {
|
|||
return !!type && BINARY_TYPE_RE.test(type);
|
||||
}
|
||||
|
||||
export function canImportBinaryCellFile(databaseType?: DatabaseType, columnType?: string): boolean {
|
||||
const type = (columnType ?? "").trim();
|
||||
if (databaseType === "postgres") return /^bytea(?:\b|\()/i.test(type);
|
||||
if (databaseType === "mysql") return MYSQL_FILE_IMPORT_TYPE_RE.test(type);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canDownloadBinaryCellValue(value: unknown, columnType?: string): boolean {
|
||||
return !!parseBinaryCellBytes(value, columnType);
|
||||
}
|
||||
|
|
@ -116,7 +205,7 @@ function binaryCellDisplayLabel(columnType?: string): string {
|
|||
return base;
|
||||
}
|
||||
|
||||
function formatBinaryCellByteSize(bytes: number): string {
|
||||
export function formatBinaryCellByteSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} bytes`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export function createDataGridCellContextMenuItems(options: {
|
|||
labels: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", string>;
|
||||
icons: Pick<DataGridContextMenuIcons, "cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose">;
|
||||
actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>;
|
||||
importItem?: DataGridContextMenuItem | null;
|
||||
downloadItem?: DataGridContextMenuItem | null;
|
||||
foreignKeyItem?: DataGridContextMenuItem | null;
|
||||
copySubmenu: DataGridContextMenuItem;
|
||||
|
|
@ -138,6 +139,7 @@ export function createDataGridCellContextMenuItems(options: {
|
|||
if (options.hasCell) {
|
||||
if (options.hasColumn) {
|
||||
items.push({ label: options.labels.cellDetails, action: options.actions.cellDetails, icon: options.icons.cellDetails });
|
||||
if (options.importItem) items.push(options.importItem);
|
||||
if (options.downloadItem) items.push(options.downloadItem);
|
||||
if (options.foreignKeyItem) items.push(options.foreignKeyItem);
|
||||
items.push({ label: options.labels.columnDetails, action: options.actions.columnDetails, icon: options.icons.columnDetails });
|
||||
|
|
|
|||
|
|
@ -1803,6 +1803,11 @@ pub fn format_grid_sql_literal(
|
|||
return literal;
|
||||
}
|
||||
}
|
||||
if is_postgres_binary_literal_column(database_type, column_info) {
|
||||
if let Some(literal) = format_postgres_binary_literal_text(&text) {
|
||||
return literal;
|
||||
}
|
||||
}
|
||||
if is_oracle_raw_literal_column(database_type, column_info) {
|
||||
if let Some(literal) = format_oracle_raw_literal_text(&text) {
|
||||
return literal;
|
||||
|
|
@ -2141,6 +2146,24 @@ fn format_mysql_binary_literal_text(text: &str) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
fn is_postgres_binary_literal_column(
|
||||
database_type: Option<DatabaseType>,
|
||||
column_info: Option<&DataGridColumnInfo>,
|
||||
) -> bool {
|
||||
database_type == Some(DatabaseType::Postgres)
|
||||
&& column_info.is_some_and(|column| column.data_type.trim().eq_ignore_ascii_case("bytea"))
|
||||
}
|
||||
|
||||
fn format_postgres_binary_literal_text(text: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
let hex = trimmed.strip_prefix("0x").or_else(|| trimmed.strip_prefix("0X"))?;
|
||||
if hex.len() % 2 == 0 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
||||
Some(format!("decode('{hex}', 'hex')"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_oracle_raw_literal_column(database_type: Option<DatabaseType>, column_info: Option<&DataGridColumnInfo>) -> bool {
|
||||
is_oracle_temporal_literal_database(database_type)
|
||||
&& column_info.map(|column| is_oracle_raw_column_type(&column.data_type)).unwrap_or(false)
|
||||
|
|
@ -5403,6 +5426,23 @@ mod tests {
|
|||
assert_eq!(format_grid_sql_literal(&json!("it's"), Some(DatabaseType::Postgres), None), "'it''s'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_bytea_literals_decode_prefixed_hex_values() {
|
||||
let bytea = column("payload", "bytea", true, None);
|
||||
let text = column("label", "text", true, None);
|
||||
|
||||
assert_eq!(
|
||||
format_grid_sql_literal(&json!("0x00aBff"), Some(DatabaseType::Postgres), Some(&bytea)),
|
||||
"decode('00aBff', 'hex')"
|
||||
);
|
||||
assert_eq!(
|
||||
format_grid_sql_literal(&json!("0x"), Some(DatabaseType::Postgres), Some(&bytea)),
|
||||
"decode('', 'hex')"
|
||||
);
|
||||
assert_eq!(format_grid_sql_literal(&json!("0xabc"), Some(DatabaseType::Postgres), Some(&bytea)), "'0xabc'");
|
||||
assert_eq!(format_grid_sql_literal(&json!("0x00ab"), Some(DatabaseType::Postgres), Some(&text)), "'0x00ab'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_family_literals_do_not_double_escape_backslashes() {
|
||||
let value = json!(r#"{"json_raw":"{\"foo\":1,\"bar\":\"sometext\"}"}"#);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,19 @@ import assert from "node:assert/strict";
|
|||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
import { binaryCellDisplayText, binaryCellDownloadFileName, binaryCellDownloadPayload, canDownloadBinaryCellValue, isBinaryCellColumnType, parseBinaryCellBytes, parseBinaryCellHexValue, retainBinaryCellDownloadMenuForHover } from "../../apps/desktop/src/lib/dataGrid/binaryCellDownload.ts";
|
||||
import {
|
||||
BinaryCellImportTooLargeError,
|
||||
binaryCellDisplayText,
|
||||
binaryCellDownloadFileName,
|
||||
binaryCellDownloadPayload,
|
||||
canDownloadBinaryCellValue,
|
||||
formatBinaryCellByteSize,
|
||||
isBinaryCellColumnType,
|
||||
MAX_BINARY_CELL_IMPORT_BYTES,
|
||||
parseBinaryCellBytes,
|
||||
parseBinaryCellHexValue,
|
||||
retainBinaryCellDownloadMenuForHover,
|
||||
} from "../../apps/desktop/src/lib/dataGrid/binaryCellDownload.ts";
|
||||
|
||||
test("parseBinaryCellHexValue accepts 0x and \\x prefixed hex values", () => {
|
||||
assert.deepEqual(Array.from(parseBinaryCellHexValue("0X48656c6c6f") ?? []), [72, 101, 108, 108, 111]);
|
||||
|
|
@ -77,3 +89,34 @@ test("binaryCellDownloadPayload decodes GBK text bytes", () => {
|
|||
test("binaryCellDownloadFileName sanitizes column names", () => {
|
||||
assert.equal(binaryCellDownloadFileName({ column: "avatar/blob", rowNumber: 7, mode: "gbk", extension: "txt" }), "avatar-blob-row-7-gbk.txt");
|
||||
});
|
||||
|
||||
test("MAX_BINARY_CELL_IMPORT_BYTES is a sane upper bound for single-cell imports", () => {
|
||||
// 16 MB: 单个 BLOB 单元格导入的保守上限,避免 readFile 全量读 + 2× hex 常驻导致 OOM。
|
||||
assert.equal(MAX_BINARY_CELL_IMPORT_BYTES, 16 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("BinaryCellImportTooLargeError carries code and byte/limit for toast formatting", () => {
|
||||
const err = new BinaryCellImportTooLargeError(20 * 1024 * 1024, MAX_BINARY_CELL_IMPORT_BYTES);
|
||||
assert.equal(err.code, "binary-import-too-large");
|
||||
assert.equal(err.bytes, 20 * 1024 * 1024);
|
||||
assert.equal(err.limit, MAX_BINARY_CELL_IMPORT_BYTES);
|
||||
assert.ok(err instanceof Error);
|
||||
});
|
||||
|
||||
test("formatBinaryCellByteSize formats human-readable sizes for the import toast", () => {
|
||||
assert.equal(formatBinaryCellByteSize(512), "512 bytes");
|
||||
assert.equal(formatBinaryCellByteSize(2048), "2.0 KB");
|
||||
// bytes >= 10 MB 时按整数 MB 显示(对齐 binaryCellDisplayText 既有格式)。
|
||||
assert.equal(formatBinaryCellByteSize(20 * 1024 * 1024), "20 MB");
|
||||
});
|
||||
|
||||
test("DataGrid import handler surfaces a dedicated too-large toast instead of the generic failure", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const handler = source.match(/async function importDetailBinaryValue\([^]*?\n\}/)?.[0] ?? "";
|
||||
|
||||
// 闸门错误必须走专门的文案,而非通用 binaryImportFailed。
|
||||
assert.match(handler, /e instanceof BinaryCellImportTooLargeError/);
|
||||
assert.match(handler, /grid\.binaryImportTooLarge/);
|
||||
assert.match(handler, /formatBinaryCellByteSize\(e\.bytes\)/);
|
||||
assert.match(handler, /formatBinaryCellByteSize\(e\.limit\)/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue