feat(grid): import BYTEA/BLOB cell from file with size gate
This commit is contained in:
20 files changed
+326
-17
No files matched your search
@@ -6,7 +6,8 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Upload,
|
Download,
|
||||||
|
FileUp,
|
||||||
Trash2,
|
Trash2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -122,12 +123,17 @@ import { getApplicablePreviewActions } from "@/lib/dataGrid/resultPreviewRegistr
|
|||||||
import "@/lib/dataGrid/geometryMapPreview";
|
import "@/lib/dataGrid/geometryMapPreview";
|
||||||
import {
|
import {
|
||||||
BINARY_CELL_DOWNLOAD_MODES,
|
BINARY_CELL_DOWNLOAD_MODES,
|
||||||
|
BinaryCellImportTooLargeError,
|
||||||
|
binaryCellBytesToHexValue,
|
||||||
binaryCellDisplayText,
|
binaryCellDisplayText,
|
||||||
binaryCellDownloadFileName,
|
binaryCellDownloadFileName,
|
||||||
binaryCellDownloadPayload,
|
binaryCellDownloadPayload,
|
||||||
|
canImportBinaryCellFile,
|
||||||
canDownloadBinaryCellValue,
|
canDownloadBinaryCellValue,
|
||||||
downloadBinaryCellPayload,
|
downloadBinaryCellPayload,
|
||||||
|
formatBinaryCellByteSize,
|
||||||
isBinaryCellColumnType,
|
isBinaryCellColumnType,
|
||||||
|
openBinaryCellFile,
|
||||||
parseBinaryCellBytes,
|
parseBinaryCellBytes,
|
||||||
retainBinaryCellDownloadMenuForHover,
|
retainBinaryCellDownloadMenuForHover,
|
||||||
type BinaryCellDownloadMode,
|
type BinaryCellDownloadMode,
|
||||||
@@ -7063,6 +7069,39 @@ function canDownloadDetailBinaryValue(detail: DataGridCellDetail | null): boolea
|
|||||||
return !!detail && canDownloadBinaryCellValue(detail.value, detail.type);
|
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 {
|
function canQuickDownloadCellValue(rowIndex: number, columnIndex: number): boolean {
|
||||||
return canDownloadDetailBinaryValue(cellDetailFor(rowIndex, columnIndex));
|
return canDownloadDetailBinaryValue(cellDetailFor(rowIndex, columnIndex));
|
||||||
}
|
}
|
||||||
@@ -7096,7 +7135,7 @@ function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuIt
|
|||||||
if (!canDownloadDetailBinaryValue(detail)) return null;
|
if (!canDownloadDetailBinaryValue(detail)) return null;
|
||||||
return {
|
return {
|
||||||
label: t("grid.downloadBinaryValue"),
|
label: t("grid.downloadBinaryValue"),
|
||||||
icon: Upload,
|
icon: Download,
|
||||||
children: BINARY_CELL_DOWNLOAD_MODES.map((mode) => ({
|
children: BINARY_CELL_DOWNLOAD_MODES.map((mode) => ({
|
||||||
label: t(`grid.binaryDownload.${mode}`),
|
label: t(`grid.binaryDownload.${mode}`),
|
||||||
action: () => {
|
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() {
|
async function copyDetailSqlCondition() {
|
||||||
if (!canCopyPreparedDetailSqlCondition()) return;
|
if (!canCopyPreparedDetailSqlCondition()) return;
|
||||||
copyText(detailSqlConditionCopy.value.text);
|
copyText(detailSqlConditionCopy.value.text);
|
||||||
@@ -8846,7 +8896,7 @@ function exportSubmenu(): ContextMenuItem {
|
|||||||
{ label: t("grid.exportSelectedRowsTxt"), action: exportSelectedRowsTxt },
|
{ 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[]>(() => {
|
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 },
|
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 },
|
actions: { cellDetails: openContextCellDetailDialog, columnDetails: openContextColumnDetailDialog, rowDetails: openContextRowDetailDialog, setNull: setSelectionNull, bulkEdit: openBulkEditDialog, transpose: openContextTranspose },
|
||||||
|
importItem: binaryImportItem(contextCellDetail.value),
|
||||||
downloadItem: binaryDownloadSubmenu(contextCellDetail.value),
|
downloadItem: binaryDownloadSubmenu(contextCellDetail.value),
|
||||||
foreignKeyItem: contextForeignKeyMenuItem(),
|
foreignKeyItem: contextForeignKeyMenuItem(),
|
||||||
copySubmenu: copySubmenu(),
|
copySubmenu: copySubmenu(),
|
||||||
@@ -9376,7 +9427,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
>
|
>
|
||||||
<template #trigger="{ open, toggle }">
|
<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">
|
<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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</LightDropdownMenu>
|
</LightDropdownMenu>
|
||||||
@@ -9890,7 +9941,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
>
|
>
|
||||||
<template #trigger="{ open, toggle }">
|
<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">
|
<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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</LightDropdownMenu>
|
</LightDropdownMenu>
|
||||||
@@ -10093,7 +10144,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
>
|
>
|
||||||
<template #trigger="{ open, toggle }">
|
<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">
|
<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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</LightDropdownMenu>
|
</LightDropdownMenu>
|
||||||
@@ -10408,6 +10459,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
:type-color-class="typeColorClass"
|
:type-color-class="typeColorClass"
|
||||||
:can-download-binary-value="canDownloadDetailBinaryValue"
|
:can-download-binary-value="canDownloadDetailBinaryValue"
|
||||||
:download-binary-value="downloadDetailBinaryValue"
|
:download-binary-value="downloadDetailBinaryValue"
|
||||||
|
:can-import-binary-value="canImportDetailBinaryValue"
|
||||||
|
:import-binary-value="importDetailBinaryValue"
|
||||||
:open-image-preview="openImagePreview"
|
:open-image-preview="openImagePreview"
|
||||||
:can-copy-sql-condition="canCopyPreparedDetailSqlCondition"
|
:can-copy-sql-condition="canCopyPreparedDetailSqlCondition"
|
||||||
@start-edit="startDetailEdit"
|
@start-edit="startDetailEdit"
|
||||||
@@ -10582,6 +10635,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
:copy-text="copyText"
|
:copy-text="copyText"
|
||||||
:can-download-binary-value="canDownloadDetailBinaryValue"
|
:can-download-binary-value="canDownloadDetailBinaryValue"
|
||||||
:download-binary-value="downloadDetailBinaryValue"
|
:download-binary-value="downloadDetailBinaryValue"
|
||||||
|
:can-import-binary-value="canImportDetailBinaryValue"
|
||||||
|
:import-binary-value="importDetailBinaryValue"
|
||||||
@edit="openDialogCellInSidePanel"
|
@edit="openDialogCellInSidePanel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from "vue";
|
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 { useI18n } from "vue-i18n";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
@@ -25,6 +25,8 @@ const props = defineProps<{
|
|||||||
copyText: (text: string) => void;
|
copyText: (text: string) => void;
|
||||||
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||||
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
|
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
|
||||||
|
canImportBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||||
|
importBinaryValue: (detail: DataGridCellDetail | null) => void | Promise<void>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -152,10 +154,13 @@ watch(
|
|||||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.copyValue')" @click="copyCurrentValue">
|
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.copyValue')" @click="copyCurrentValue">
|
||||||
<Copy class="h-3 w-3" />
|
<Copy class="h-3 w-3" />
|
||||||
</Button>
|
</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)">
|
<DropdownMenu v-if="canDownloadBinaryValue(detail)">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.downloadBinaryValue')">
|
<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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" class="w-44">
|
<DropdownMenuContent align="end" class="w-44">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { toRef } from "vue";
|
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 { useI18n } from "vue-i18n";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
@@ -30,6 +30,8 @@ const props = defineProps<{
|
|||||||
typeColorClass: (type: string) => string;
|
typeColorClass: (type: string) => string;
|
||||||
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
canDownloadBinaryValue: (detail: DataGridCellDetail | null) => boolean;
|
||||||
downloadBinaryValue: (detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) => void | Promise<void>;
|
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;
|
openImagePreview: (src: string, title: string) => void;
|
||||||
canCopySqlCondition: () => boolean;
|
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.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 && 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="!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)"
|
<DropdownMenu v-if="!editing && canDownloadBinaryValue(detail)"
|
||||||
><DropdownMenuTrigger as-child
|
><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"
|
><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
|
><DropdownMenuItem v-for="mode in BINARY_CELL_DOWNLOAD_MODES" :key="mode" @click="downloadBinaryValue(detail, mode)">{{ t(`grid.binaryDownload.${mode}`) }}</DropdownMenuItem></DropdownMenuContent
|
||||||
></DropdownMenu
|
></DropdownMenu
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Upload } from "@lucide/vue";
|
import { Download } from "@lucide/vue";
|
||||||
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
|
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -14,7 +14,7 @@ defineProps<{
|
|||||||
model-value=""
|
model-value=""
|
||||||
:items="items"
|
:items="items"
|
||||||
:aria-label="label"
|
:aria-label="label"
|
||||||
:trigger-icon="Upload"
|
:trigger-icon="Download"
|
||||||
:trigger-label="label"
|
: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-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"
|
trigger-icon-class="h-3.5 w-3.5"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -164,7 +164,7 @@ function actionLabelClass() {
|
|||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="exportData?.disabled || !exportData?.items.length">
|
<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>
|
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ exportData?.label }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ vi.mock("@lucide/vue", async () => {
|
|||||||
ChevronRight: icon,
|
ChevronRight: icon,
|
||||||
ChevronsLeft: icon,
|
ChevronsLeft: icon,
|
||||||
ChevronsRight: icon,
|
ChevronsRight: icon,
|
||||||
|
Download: icon,
|
||||||
Filter: icon,
|
Filter: icon,
|
||||||
Loader2: icon,
|
Loader2: icon,
|
||||||
|
FileUp: icon,
|
||||||
Upload: icon,
|
Upload: icon,
|
||||||
Search: icon,
|
Search: icon,
|
||||||
X: icon,
|
X: icon,
|
||||||
@@ -873,7 +875,20 @@ describe("cell detail surfaces", () => {
|
|||||||
const copyText = vi.fn();
|
const copyText = vi.fn();
|
||||||
const edit = vi.fn();
|
const edit = vi.fn();
|
||||||
const updateOpen = 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();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
@@ -885,6 +900,11 @@ describe("cell detail surfaces", () => {
|
|||||||
"click",
|
"click",
|
||||||
);
|
);
|
||||||
expect(edit).toHaveBeenCalledOnce();
|
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}' }) });
|
await mounted.setProps({ detail: detail({ rawValue: '{"b":2}', formattedJson: '{\n "b": 2\n}' }) });
|
||||||
expect(mocks.editor.setValue).toHaveBeenCalledWith('{\n "b": 2\n}', "json");
|
expect(mocks.editor.setValue).toHaveBeenCalledWith('{\n "b": 2\n}', "json");
|
||||||
@@ -912,6 +932,8 @@ describe("cell detail surfaces", () => {
|
|||||||
typeColorClass: () => "",
|
typeColorClass: () => "",
|
||||||
canDownloadBinaryValue: () => false,
|
canDownloadBinaryValue: () => false,
|
||||||
downloadBinaryValue: vi.fn(),
|
downloadBinaryValue: vi.fn(),
|
||||||
|
canImportBinaryValue: () => false,
|
||||||
|
importBinaryValue: vi.fn(),
|
||||||
openImagePreview: vi.fn(),
|
openImagePreview: vi.fn(),
|
||||||
canCopySqlCondition: () => true,
|
canCopySqlCondition: () => true,
|
||||||
onStartEdit: startEdit,
|
onStartEdit: startEdit,
|
||||||
|
|||||||
@@ -1649,6 +1649,10 @@ export default {
|
|||||||
rawValue: "Raw Value",
|
rawValue: "Raw Value",
|
||||||
largeValuePreviewHint: "Previewing first {count} characters. Copy still uses the full value.",
|
largeValuePreviewHint: "Previewing first {count} characters. Copy still uses the full value.",
|
||||||
copyValue: "Copy 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",
|
downloadBinaryValue: "Download Value",
|
||||||
downloadSaved: "Saved to {path}",
|
downloadSaved: "Saved to {path}",
|
||||||
downloadStarted: "Download started: {fileName}",
|
downloadStarted: "Download started: {fileName}",
|
||||||
|
|||||||
@@ -1675,6 +1675,10 @@ export default withEnglishFallback({
|
|||||||
xlsxHeaderOriginal: "Encabezado usando nombres de campos",
|
xlsxHeaderOriginal: "Encabezado usando nombres de campos",
|
||||||
xlsxHeaderComment: "Encabezado usando comentarios",
|
xlsxHeaderComment: "Encabezado usando comentarios",
|
||||||
jumpToPage: "Ir a la página",
|
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: {
|
exportProgress: {
|
||||||
streamingUnsupported: "La exportación en streaming no es compatible con esta consulta. Simplifíquela o use un controlador compatible.",
|
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",
|
xlsxHeaderOriginal: "Intestazione con nome campo",
|
||||||
xlsxHeaderComment: "Intestazione con commento",
|
xlsxHeaderComment: "Intestazione con commento",
|
||||||
jumpToPage: "Vai alla pagina",
|
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: {
|
exportProgress: {
|
||||||
streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.",
|
streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.",
|
||||||
|
|||||||
@@ -1700,6 +1700,10 @@ export default withEnglishFallback({
|
|||||||
searchModeHighlight: "ハイライト",
|
searchModeHighlight: "ハイライト",
|
||||||
emptyStringValue: "空文字",
|
emptyStringValue: "空文字",
|
||||||
jumpToPage: "ページ番号に移動",
|
jumpToPage: "ページ番号に移動",
|
||||||
|
importBinaryValue: "ファイルからインポート",
|
||||||
|
binaryImportApplied: "{count} バイトをインポートしました。変更を保存するとデータベースに書き込まれます。",
|
||||||
|
binaryImportFailed: "ファイルのインポートに失敗しました:{message}",
|
||||||
|
binaryImportTooLarge: "ファイルが大きすぎます({size}、上限 {limit})。大きなファイルの単一セルへのインポートはサポートされていません。",
|
||||||
},
|
},
|
||||||
exportProgress: {
|
exportProgress: {
|
||||||
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
|
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
|
||||||
|
|||||||
@@ -1519,6 +1519,7 @@ export default withEnglishFallback({
|
|||||||
largeValuePreviewHint: "처음 {count}자 미리보는 중. 복사에는 여전히 전체 값이 사용됩니다.",
|
largeValuePreviewHint: "처음 {count}자 미리보는 중. 복사에는 여전히 전체 값이 사용됩니다.",
|
||||||
copyValue: "값 복사",
|
copyValue: "값 복사",
|
||||||
downloadBinaryValue: "값 다운로드",
|
downloadBinaryValue: "값 다운로드",
|
||||||
|
binaryImportTooLarge: "파일이 너무 큽니다({size}, 상한 {limit}). 큰 파일을 단일 셀에 가져오는 것은 지원되지 않습니다.",
|
||||||
downloadSaved: "{path}에 저장됨",
|
downloadSaved: "{path}에 저장됨",
|
||||||
downloadStarted: "다운로드 시작됨: {fileName}",
|
downloadStarted: "다운로드 시작됨: {fileName}",
|
||||||
binaryDownload: {
|
binaryDownload: {
|
||||||
|
|||||||
@@ -1675,6 +1675,10 @@ export default withEnglishFallback({
|
|||||||
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",
|
xlsxHeaderOriginal: "Cabeçalho usa nome do campo",
|
||||||
xlsxHeaderComment: "Cabeçalho usa comentário",
|
xlsxHeaderComment: "Cabeçalho usa comentário",
|
||||||
jumpToPage: "Ir para a página",
|
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: {
|
exportProgress: {
|
||||||
streamingUnsupported: "A exportação em streaming não é compatível com esta consulta. Simplifique-a ou use um driver compatível.",
|
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: "原始值",
|
rawValue: "原始值",
|
||||||
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
|
largeValuePreviewHint: "仅预览前 {count} 个字符,复制仍会使用完整值。",
|
||||||
copyValue: "复制值",
|
copyValue: "复制值",
|
||||||
|
importBinaryValue: "从文件导入",
|
||||||
|
binaryImportApplied: "已导入 {count} 字节,保存更改后写入数据库。",
|
||||||
|
binaryImportFailed: "导入文件失败:{message}",
|
||||||
|
binaryImportTooLarge: "文件过大({size},上限 {limit})。不支持将大文件导入到单个单元格。",
|
||||||
downloadBinaryValue: "下载值",
|
downloadBinaryValue: "下载值",
|
||||||
downloadSaved: "已保存到 {path}",
|
downloadSaved: "已保存到 {path}",
|
||||||
downloadStarted: "已交给浏览器下载:{fileName}",
|
downloadStarted: "已交给浏览器下载:{fileName}",
|
||||||
|
|||||||
@@ -1524,6 +1524,10 @@ export default withEnglishFallback({
|
|||||||
rawValue: "原始值",
|
rawValue: "原始值",
|
||||||
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
|
largeValuePreviewHint: "僅預覽前 {count} 個字元,複製仍會使用完整值。",
|
||||||
copyValue: "複製值",
|
copyValue: "複製值",
|
||||||
|
importBinaryValue: "從檔案匯入",
|
||||||
|
binaryImportApplied: "已匯入 {count} 位元組,儲存變更後寫入資料庫。",
|
||||||
|
binaryImportFailed: "匯入檔案失敗:{message}",
|
||||||
|
binaryImportTooLarge: "檔案過大({size},上限 {limit})。不支援將大檔案匯入到單一儲存格。",
|
||||||
downloadBinaryValue: "下載值",
|
downloadBinaryValue: "下載值",
|
||||||
downloadSaved: "已儲存到 {path}",
|
downloadSaved: "已儲存到 {path}",
|
||||||
downloadStarted: "已交給瀏覽器下載:{fileName}",
|
downloadStarted: "已交給瀏覽器下載:{fileName}",
|
||||||
|
|||||||
@@ -63,9 +63,12 @@ describe("dataGridContextMenu", () => {
|
|||||||
labels: { cellDetails: "cell", columnDetails: "column", rowDetails: "row", setNull: "null", bulkEdit: "bulk", transpose: "transpose" },
|
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 },
|
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 },
|
actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action },
|
||||||
|
importItem: { label: "import" },
|
||||||
|
downloadItem: { label: "download" },
|
||||||
copySubmenu: { label: "copy" },
|
copySubmenu: { label: "copy" },
|
||||||
clearSelectionItem: { label: "clear" },
|
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);
|
expect(cellItems.find((item) => item.label === "bulk")?.disabled).toBe(true);
|
||||||
|
|
||||||
const rowItems = createDataGridRowContextMenuItems({
|
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 type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||||
|
import type { DatabaseType } from "@/types/database";
|
||||||
|
|
||||||
export type BinaryCellDownloadMode = "binary" | "utf8" | "gbk";
|
export type BinaryCellDownloadMode = "binary" | "utf8" | "gbk";
|
||||||
|
|
||||||
@@ -22,6 +23,86 @@ export interface BinaryCellDownloadResult {
|
|||||||
|
|
||||||
export const BINARY_CELL_DOWNLOAD_MODES: BinaryCellDownloadMode[] = ["binary", "utf8", "gbk"];
|
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 {
|
export function retainBinaryCellDownloadMenuForHover(openCell: BinaryCellPosition | null, hoveredCell: BinaryCellPosition): BinaryCellPosition | null {
|
||||||
return openCell?.rowIndex === hoveredCell.rowIndex && openCell.col === hoveredCell.col ? openCell : 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 BARE_HEX_RE = /^[0-9a-fA-F\s]+$/;
|
||||||
const HEX_ESCAPE_RE = /^(?:\\x[0-9a-fA-F]{2}|\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 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> {
|
function copyBytesForBlob(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
return new Uint8Array(bytes);
|
return new Uint8Array(bytes);
|
||||||
@@ -96,6 +178,13 @@ export function isBinaryCellColumnType(columnType?: string): boolean {
|
|||||||
return !!type && BINARY_TYPE_RE.test(type);
|
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 {
|
export function canDownloadBinaryCellValue(value: unknown, columnType?: string): boolean {
|
||||||
return !!parseBinaryCellBytes(value, columnType);
|
return !!parseBinaryCellBytes(value, columnType);
|
||||||
}
|
}
|
||||||
@@ -116,7 +205,7 @@ function binaryCellDisplayLabel(columnType?: string): string {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBinaryCellByteSize(bytes: number): string {
|
export function formatBinaryCellByteSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} bytes`;
|
if (bytes < 1024) return `${bytes} bytes`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
|
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`;
|
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>;
|
labels: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", string>;
|
||||||
icons: Pick<DataGridContextMenuIcons, "cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose">;
|
icons: Pick<DataGridContextMenuIcons, "cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose">;
|
||||||
actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>;
|
actions: Record<"cellDetails" | "columnDetails" | "rowDetails" | "setNull" | "bulkEdit" | "transpose", () => void>;
|
||||||
|
importItem?: DataGridContextMenuItem | null;
|
||||||
downloadItem?: DataGridContextMenuItem | null;
|
downloadItem?: DataGridContextMenuItem | null;
|
||||||
foreignKeyItem?: DataGridContextMenuItem | null;
|
foreignKeyItem?: DataGridContextMenuItem | null;
|
||||||
copySubmenu: DataGridContextMenuItem;
|
copySubmenu: DataGridContextMenuItem;
|
||||||
@@ -138,6 +139,7 @@ export function createDataGridCellContextMenuItems(options: {
|
|||||||
if (options.hasCell) {
|
if (options.hasCell) {
|
||||||
if (options.hasColumn) {
|
if (options.hasColumn) {
|
||||||
items.push({ label: options.labels.cellDetails, action: options.actions.cellDetails, icon: options.icons.cellDetails });
|
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.downloadItem) items.push(options.downloadItem);
|
||||||
if (options.foreignKeyItem) items.push(options.foreignKeyItem);
|
if (options.foreignKeyItem) items.push(options.foreignKeyItem);
|
||||||
items.push({ label: options.labels.columnDetails, action: options.actions.columnDetails, icon: options.icons.columnDetails });
|
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;
|
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 is_oracle_raw_literal_column(database_type, column_info) {
|
||||||
if let Some(literal) = format_oracle_raw_literal_text(&text) {
|
if let Some(literal) = format_oracle_raw_literal_text(&text) {
|
||||||
return literal;
|
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 {
|
fn is_oracle_raw_literal_column(database_type: Option<DatabaseType>, column_info: Option<&DataGridColumnInfo>) -> bool {
|
||||||
is_oracle_temporal_literal_database(database_type)
|
is_oracle_temporal_literal_database(database_type)
|
||||||
&& column_info.map(|column| is_oracle_raw_column_type(&column.data_type)).unwrap_or(false)
|
&& 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'");
|
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]
|
#[test]
|
||||||
fn sqlite_family_literals_do_not_double_escape_backslashes() {
|
fn sqlite_family_literals_do_not_double_escape_backslashes() {
|
||||||
let value = json!(r#"{"json_raw":"{\"foo\":1,\"bar\":\"sometext\"}"}"#);
|
let value = json!(r#"{"json_raw":"{\"foo\":1,\"bar\":\"sometext\"}"}"#);
|
||||||
|
|||||||
+44
-1
@@ -2,7 +2,19 @@ import assert from "node:assert/strict";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { test } from "vitest";
|
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", () => {
|
test("parseBinaryCellHexValue accepts 0x and \\x prefixed hex values", () => {
|
||||||
assert.deepEqual(Array.from(parseBinaryCellHexValue("0X48656c6c6f") ?? []), [72, 101, 108, 108, 111]);
|
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", () => {
|
test("binaryCellDownloadFileName sanitizes column names", () => {
|
||||||
assert.equal(binaryCellDownloadFileName({ column: "avatar/blob", rowNumber: 7, mode: "gbk", extension: "txt" }), "avatar-blob-row-7-gbk.txt");
|
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\)/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user