feat(grid): add cell value conversion previews

Refs: #7751
This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-11 12:07:36 +08:00
1 parent 5cd4706d3d
commit 6299d62125
19 files changed
+903 -4

No files matched your search

+14 -1
View File
@@ -67,6 +67,7 @@ import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import DataGridValueTransform from "@/components/grid/DataGridValueTransform.vue";
import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.vue";
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
@@ -5782,6 +5783,10 @@ const showCompactDetailJson = computed(() => {
// CodeMirror-based cell detail editors
const valueEditorContainer = ref<HTMLElement>();
const detailTransformOpen = ref(false);
watch([showCellDetail, activeCellDetailTab, () => activeCellDetail.value?.rowId, () => activeCellDetail.value?.colIndex], () => {
detailTransformOpen.value = false;
});
let valueDetailEditor: UseCellDetailEditorReturn | null = null;
const editorThemeAccessor = () => settingsStore.editorSettings.theme;
@@ -5812,7 +5817,7 @@ watch(valueEditorContainer, async (el) => {
},
onEscape: () => restoreDetailOriginalValue(),
onBlur: () => {
if (!detailValueDiffOpen.value) commitValueEditorEdit();
if (!detailValueDiffOpen.value && !detailTransformOpen.value) commitValueEditorEdit();
},
editorTheme: editorThemeAccessor,
appAppearance: editorAppAppearance,
@@ -13204,6 +13209,14 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<div v-else ref="valueEditorContainer" data-cell-detail-editor-root class="min-h-0 min-w-0 flex-1 w-full rounded border overflow-auto" />
</div>
<div class="min-w-0 flex flex-wrap gap-1 mt-2 shrink-0">
<DataGridValueTransform
v-if="activeCellDetail && !isBinaryCellColumnType(activeCellDetail.type)"
v-model:open="detailTransformOpen"
:source="isEditingDetail && !((activeCellDetail.isNull ?? activeCellDetail.value === null) && detailEditValue === detailEditOriginalValue) ? detailEditValue : null"
:identity="`${activeCellDetail.rowId}:${activeCellDetail.colIndex}:${activeCellDetailTab}`"
:incomplete="activeCellDetail.isSourceTruncated"
:unsafe-number="typeof activeCellDetail.value === 'number' && Number.isInteger(activeCellDetail.value) && !Number.isSafeInteger(activeCellDetail.value) && detailEditValue === activeCellDetail.rawValue"
/>
<DropdownMenu v-if="activeCellDetail?.isEditable">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-6 gap-1 text-xs" @mousedown.prevent>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import DataGridValueTransform from "@/components/grid/DataGridValueTransform.vue";
import { computed, nextTick, ref, watch } from "vue";
import { Code2, Copy, Download, Eye, FileUp, Info, Pencil } from "@lucide/vue";
import { useI18n } from "vue-i18n";
@@ -9,7 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { BINARY_CELL_DOWNLOAD_MODES, binaryCellUtf8Text, isBlobCellColumnType, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
import { BINARY_CELL_DOWNLOAD_MODES, isBinaryCellColumnType, binaryCellUtf8Text, isBlobCellColumnType, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
import { isGeometryColumnType } from "@/lib/dataGrid/cellDetailPresentation";
import { isHexGeometry, renderWktOnCanvas } from "@/lib/dataGrid/geometryPreview";
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
@@ -149,6 +150,13 @@ watch(
<div class="flex items-center justify-between gap-2">
<div class="text-muted-foreground">{{ t("grid.cellValue") }}</div>
<div class="flex items-center gap-1">
<DataGridValueTransform
v-if="open && !isBinaryCellColumnType(detail.type)"
:source="(detail.isNull ?? detail.value === null) ? null : detail.rawValue"
:identity="`${detail.rowId}:${detail.colIndex}`"
:incomplete="detail.isSourceTruncated"
:unsafe-number="typeof detail.value === 'number' && Number.isInteger(detail.value) && !Number.isSafeInteger(detail.value)"
/>
<Button v-if="detail.formattedJson" :variant="jsonView ? 'secondary' : 'ghost'" size="sm" class="h-6 gap-1 px-2 text-xs" :title="t('grid.formattedJson')" @click="toggleJsonFormatted">
<Code2 class="h-3 w-3" />
{{ t("grid.formattedJson") }}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import DataGridValueTransform from "@/components/grid/DataGridValueTransform.vue";
import { computed, toRef } from "vue";
import { Code2, Copy, Download, Eye, FileDiff, FileUp, Pencil, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
@@ -8,7 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { TabsContent } from "@/components/ui/tabs";
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import { useDataGridCellDetail } from "@/composables/useDataGridCellDetail";
import { BINARY_CELL_DOWNLOAD_MODES, binaryCellUtf8Text, isBlobCellColumnType, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
import { BINARY_CELL_DOWNLOAD_MODES, isBinaryCellColumnType, binaryCellUtf8Text, isBlobCellColumnType, type BinaryCellDownloadMode } from "@/lib/dataGrid/binaryCellDownload";
import { isGeometryColumnType } from "@/lib/dataGrid/cellDetailPresentation";
import { isHexGeometry } from "@/lib/dataGrid/geometryPreview";
import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail";
@@ -141,6 +142,13 @@ defineExpose({ openSearch });
<div class="flex min-h-5 min-w-0 flex-wrap items-center justify-between gap-2">
<div class="shrink-0 text-muted-foreground">{{ t("grid.cellValue") }}</div>
<div class="min-w-0 flex flex-1 flex-wrap items-center justify-end gap-1">
<DataGridValueTransform
v-if="!isBinaryCellColumnType(detail.type)"
:source="(detail.isNull ?? detail.value === null) && (!editing || !detailEditValue || detailEditValue === detail.rawValue) ? null : editing ? detailEditValue : detail.rawValue"
:identity="`${detail.rowId}:${detail.colIndex}:${editing}`"
:incomplete="detail.isSourceTruncated"
:unsafe-number="typeof detail.value === 'number' && Number.isInteger(detail.value) && !Number.isSafeInteger(detail.value) && (!editing || detailEditValue === detail.rawValue)"
/>
<Button v-if="editing && showCompareJson" variant="ghost" size="sm" class="h-5 gap-1 px-1.5 text-xs" :disabled="!canCompareJson" :title="t('grid.compareJson')" @mousedown.prevent @click="emit('compareJson')"><FileDiff class="h-3 w-3" />{{ t("grid.compareJson") }}</Button>
<Button v-if="showCompactJson" variant="ghost" size="sm" class="h-5 gap-1 px-1.5 text-xs" :disabled="!canCompactJson" :title="t('grid.compactJson')" @click="emit('compactJson')"><Code2 class="h-3 w-3" />{{ t("grid.compactJson") }}</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>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed, ref, useId, watch } from "vue";
import { useI18n } from "vue-i18n";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { CELL_TRANSFORM_KINDS, CELL_TRANSFORM_MAX_INPUT, CELL_TRANSFORM_MAX_OUTPUT, CELL_TRANSFORM_MAX_RADIX_DIGITS, transformCellValue, type CellTransformKind, type CellTransformResult } from "@/lib/dataGrid/cellValueTransform";
import { DataGridDateTimePatterns, getSupportedTimeZoneOptions } from "@/lib/dataGrid/columnFormatter";
const props = defineProps<{
source: string | null;
identity: string;
incomplete?: boolean;
unsafeNumber?: boolean;
}>();
const { t } = useI18n();
const { toast } = useToast();
const id = useId();
const open = defineModel<boolean>("open", { default: false });
const kind = ref<CellTransformKind>("timestamp");
const unit = ref<"auto" | "seconds" | "milliseconds">("auto");
const timezone = ref(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
const zones = [...new Set(["UTC", timezone.value, ...getSupportedTimeZoneOptions(Intl as unknown as { supportedValuesOf?: (key: "timeZone") => string[] }, timezone.value)])];
const pattern = ref("YYYY-MM-DD HH:mm:ss");
const fromBase = ref(10);
const toBase = ref(16);
const result = ref<CellTransformResult | null>(null);
let previousEditorFocus: HTMLElement | null = null;
let previousEditorIdentity = "";
function rememberEditorFocus() {
const active = document.activeElement;
previousEditorFocus = active instanceof HTMLElement && active.closest("[data-cell-detail-editor-root]") ? active : null;
previousEditorIdentity = props.identity;
}
function restoreEditorFocus(event: Event) {
const target = previousEditorFocus;
previousEditorFocus = null;
if (!target?.isConnected || previousEditorIdentity !== props.identity) return;
// Opening the preview skips the editor's blur commit. Restore its focus so
// the next deliberate departure still commits the untouched editing draft.
event.preventDefault();
target.focus({ preventScroll: true });
}
const blockedReason = computed(() => {
if (props.source === null) return "nullSource";
if (props.incomplete) return "incomplete";
if (props.unsafeNumber) return "unsafeNumber";
if (props.source.length > CELL_TRANSFORM_MAX_INPUT) return "tooLarge";
return "";
});
// Locale messages only interpolate the grouped limit digits, so translations keep their wording.
const errorLimits = {
maxInput: new Intl.NumberFormat("en-US").format(CELL_TRANSFORM_MAX_INPUT),
maxOutput: new Intl.NumberFormat("en-US").format(CELL_TRANSFORM_MAX_OUTPUT),
maxRadixDigits: new Intl.NumberFormat("en-US").format(CELL_TRANSFORM_MAX_RADIX_DIGITS),
};
// Keep only options between cells. Results must always belong to the current source.
watch(
() => props.identity,
() => {
open.value = false;
result.value = null;
},
{ flush: "sync" },
);
watch(
[() => props.source, () => props.incomplete, () => props.unsafeNumber, kind, unit, timezone, pattern, fromBase, toBase, open],
() => {
result.value = null;
},
{ flush: "sync" },
);
function convert() {
if (blockedReason.value || props.source === null) return;
result.value = transformCellValue(props.source, { kind: kind.value, unit: unit.value, timezone: timezone.value, pattern: pattern.value, fromBase: fromBase.value, toBase: toBase.value });
}
async function copyResult() {
if (!result.value?.ok) return;
try {
await copyToClipboard(result.value.text);
toast(t("grid.cellValueCopied"), 2000);
} catch (error) {
toast(error instanceof Error ? error.message : String(error), 5000);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button variant="outline" size="sm" class="h-6 px-2 text-xs" @mousedown.prevent>{{ t("cellTransform.title") }}</Button>
</DialogTrigger>
<DialogContent class="flex max-h-[85vh] flex-col overflow-y-auto sm:max-w-2xl" data-native-clipboard @open-auto-focus="rememberEditorFocus" @close-auto-focus="restoreEditorFocus">
<DialogHeader>
<DialogTitle>{{ t("cellTransform.title") }}</DialogTitle>
<DialogDescription>{{ t("cellTransform.description") }}</DialogDescription>
</DialogHeader>
<div class="flex flex-wrap items-end gap-3 text-xs">
<label class="flex flex-col gap-1">
{{ t("cellTransform.format") }}
<select v-model="kind" class="h-8 rounded border bg-background px-2">
<option v-for="item in CELL_TRANSFORM_KINDS" :key="item" :value="item">{{ t(`cellTransform.kinds.${item}`) }}</option>
</select>
</label>
<template v-if="kind === 'timestamp'">
<label class="flex flex-col gap-1">
{{ t("cellTransform.unit") }}
<select v-model="unit" class="h-8 rounded border bg-background px-2">
<option v-for="item in ['auto', 'seconds', 'milliseconds']" :key="item" :value="item">{{ t(`cellTransform.units.${item}`) }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
{{ t("cellTransform.timezone") }}
<input v-model="timezone" :list="`${id}-zones`" class="h-8 rounded border bg-background px-2" />
<datalist :id="`${id}-zones`"><option v-for="zone in zones" :key="zone" :value="zone" /></datalist>
</label>
<label class="flex min-w-0 flex-col gap-1">
{{ t("cellTransform.pattern") }}
<select v-model="pattern" class="h-8 max-w-full rounded border bg-background px-2">
<option v-for="item in DataGridDateTimePatterns" :key="item" :value="item">{{ item }}</option>
</select>
</label>
</template>
<template v-if="kind === 'radix'">
<label class="flex flex-col gap-1">
{{ t("cellTransform.fromBase") }}
<select v-model="fromBase" class="h-8 rounded border bg-background px-2">
<option v-for="base in [2, 8, 10, 16]" :key="base" :value="base">{{ base }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
{{ t("cellTransform.toBase") }}
<select v-model="toBase" class="h-8 rounded border bg-background px-2">
<option v-for="base in [2, 8, 10, 16]" :key="base" :value="base">{{ base }}</option>
</select>
</label>
</template>
<Button size="sm" class="h-8" :disabled="!!blockedReason" @click="convert">{{ t("cellTransform.convert") }}</Button>
</div>
<p v-if="kind === 'radix'" class="text-xs text-muted-foreground">{{ t("cellTransform.radixHint") }}</p>
<p v-if="kind === 'urlEncode' || kind === 'urlDecode'" class="text-xs text-muted-foreground">{{ t("cellTransform.urlHint") }}</p>
<p v-if="blockedReason" role="status" class="text-xs text-muted-foreground">{{ t(`cellTransform.errors.${blockedReason}`, errorLimits) }}</p>
<p v-else-if="result && !result.ok" role="alert" class="text-xs text-destructive">{{ t(`cellTransform.errors.${result.error}`, errorLimits) }}</p>
<template v-if="result?.ok">
<div class="flex items-center justify-between gap-2 text-xs">
<span
>{{ t("cellTransform.result") }}<template v-if="result.unit"> · {{ t(`cellTransform.units.${result.unit}`) }}</template></span
>
<Button variant="outline" size="sm" class="h-6 text-xs" @click="copyResult">{{ t("cellTransform.copy") }}</Button>
</div>
<textarea :value="result.text" :aria-label="t('cellTransform.result')" readonly class="dbx-data-grid-value-font h-64 min-h-24 w-full resize-y rounded border bg-muted/20 p-3 text-xs" spellcheck="false" />
</template>
</DialogContent>
</Dialog>
</template>
@@ -76,6 +76,7 @@ vi.mock("@/components/ui/label", async () => ({ Label: (await import("./vueHostH
vi.mock("@/components/ui/LightDropdown.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightDropdown") }));
vi.mock("@/components/ui/LightTooltip.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightTooltip") }));
vi.mock("@/components/grid/TemporalCellEditor.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("TemporalCellEditor") }));
vi.mock("@/components/grid/DataGridValueTransform.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("DataGridValueTransform") }));
vi.mock("@/composables/useCellDetailEditor", () => ({ useCellDetailEditor: () => mocks.editor }));
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: {} } }) }));
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { cellDetailJsonFormatted: true, theme: "default", fontSize: 13, fontFamily: "monospace" }, updateEditorSettings: mocks.updateSettings }) }));
@@ -1686,7 +1687,7 @@ describe("cell detail surfaces", () => {
it("snapshots comparison values before opening and suppresses modal-induced blur commits", () => {
expect(dataGridCellDetailEditSource).toContain("detailValueDiffSnapshot.value = snapshot;");
expect(dataGridCellDetailEditSource).toContain("detailValueDiffOpen.value = true;");
expect(dataGridSource).toContain("if (!detailValueDiffOpen.value) commitValueEditorEdit();");
expect(dataGridSource).toContain("if (!detailValueDiffOpen.value && !detailTransformOpen.value) commitValueEditorEdit();");
expect(dataGridSource).toContain(':disabled="!canCompareDetailJson" @mousedown.prevent @click="openDetailJsonCompare"');
expect(dataGridSource).toContain('v-model:open="detailValueDiffOpen" :snapshot="detailValueDiffSnapshot"');
});
+42
View File
@@ -80,6 +80,48 @@ const consul = {
};
export default withEnglishFallback({
cellTransform: {
title: "Çevirmə önizləməsi",
description: "Cari dəyəri baxmaq və ya köçürmək üçün çevirir. İlkin dəyər və redaktə qaralaması dəyişmir.",
format: "Çevirmə",
unit: "Zaman damğasının vahidi",
timezone: "Saat qurşağı",
pattern: "Tarix formatı",
fromBase: "Mənbə say sistemi",
toBase: "Hədəf say sistemi",
convert: "Çevir",
result: "Nəticə",
copy: "Nəticəni köçür",
radixHint: "Yalnız tam ədədlər. 0x, 0o və ya 0b prefiksi olmadan rəqəmlər daxil edin; işarə istifadə oluna bilər.",
urlHint: "URL komponentləri UTF-8 ilə kodlanır və ya dekodlaşdırılır. Dekodlaşdırma + işarəsini boşluğa çevirmir.",
kinds: {
timestamp: "Zaman damğası → tarix/saat",
json: "JSON formatla",
jsonCompact: "JSON sıxlaşdır",
xml: "XML formatla",
base64Encode: "Mətn → Base64 (UTF-8)",
base64Decode: "Base64 → mətn (UTF-8)",
urlEncode: "URL komponentini kodla",
urlDecode: "URL komponentini dekodlaşdır",
radix: "Tam ədədin say sistemini çevir",
},
units: {
auto: "Avtomatik (10/13 rəqəm)",
seconds: "Saniyə",
milliseconds: "Millisaniyə",
},
errors: {
nullSource: "NULL çevriləcək mətn ehtiva etmir.",
incomplete: "Tam dəyər hələ yüklənməyib. Gözləyin və ya xana təfərrüatlarını yenidən açın.",
unsafeNumber: "Bu ədəd dəqiq təqdim edilə bilmir. Dəqiqliyi qorumaq üçün onu mətn kimi sorğulayın.",
tooLarge: "Çevirmə həddi aşılıb: giriş {maxInput} simvol, çıxış {maxOutput} simvol və ya tam ədəd girişi {maxRadixDigits} simvol.",
invalid: "Çevirmək mümkün deyil. Giriş formatını və çevirmə seçimlərini yoxlayın.",
invalidTimestamp: "Dəstəklənən tarix aralığında tam ədəd zaman damğası daxil edin.",
ambiguousUnit: "Vahidi müəyyən etmək mümkün deyil. Saniyə və ya millisaniyə seçin.",
invalidInteger: "Mənbə say sisteminə uyğun tam ədədi prefikssiz daxil edin.",
invalidBase64: "Etibarlı UTF-8 mətni olan standart doldurmalı Base64 daxil edin.",
},
},
customType: {
kinds: {
base: "Əsas",
+42
View File
@@ -80,6 +80,48 @@ const consul = {
};
export default {
cellTransform: {
title: "Convert preview",
description: "Convert the current value for viewing or copying. The source value and editor draft stay unchanged.",
format: "Conversion",
unit: "Timestamp unit",
timezone: "Time zone",
pattern: "Date format",
fromBase: "Source base",
toBase: "Target base",
convert: "Convert",
result: "Result",
copy: "Copy result",
radixHint: "Integers only. Enter digits without a 0x, 0o or 0b prefix; an optional sign is supported.",
urlHint: "URL component encoding (UTF-8). Decoding preserves + as a literal plus sign.",
kinds: {
timestamp: "Timestamp → date/time",
json: "Format JSON",
jsonCompact: "Compact JSON",
xml: "Format XML",
base64Encode: "Text → Base64 (UTF-8)",
base64Decode: "Base64 → text (UTF-8)",
urlEncode: "URL component encode",
urlDecode: "URL component decode",
radix: "Integer base conversion",
},
units: {
auto: "Auto (10/13 digits)",
seconds: "Seconds",
milliseconds: "Milliseconds",
},
errors: {
nullSource: "NULL has no text to convert.",
incomplete: "The full value has not loaded. Wait for it to load or reopen the cell details.",
unsafeNumber: "This numeric value cannot be represented safely. Retrieve it as text to preserve precision.",
tooLarge: "Conversion limit exceeded: {maxInput} input characters, {maxOutput} output characters, or {maxRadixDigits} integer characters.",
invalid: "Unable to convert. Check the input format and conversion options.",
invalidTimestamp: "Enter an integer timestamp within the supported date range.",
ambiguousUnit: "Cannot determine the timestamp unit. Select seconds or milliseconds explicitly.",
invalidInteger: "Enter an integer with digits valid for the selected source base, without a base prefix.",
invalidBase64: "Enter standard padded Base64 containing valid UTF-8 text.",
},
},
redisGrouping,
customType: {
kinds: {
+42
View File
@@ -83,6 +83,48 @@ const consul = {
};
export default withEnglishFallback({
cellTransform: {
title: "Vista previa de conversión",
description: "Convierte el valor actual para verlo o copiarlo. El valor original y el borrador no cambian.",
format: "Conversión",
unit: "Unidad de la marca de tiempo",
timezone: "Zona horaria",
pattern: "Formato de fecha",
fromBase: "Base de origen",
toBase: "Base de destino",
convert: "Convertir",
result: "Resultado",
copy: "Copiar resultado",
radixHint: "Solo enteros. Introduce dígitos sin los prefijos 0x, 0o o 0b; se admite un signo opcional.",
urlHint: "Codificación de componentes URL en UTF-8. La decodificación conserva + como signo más.",
kinds: {
timestamp: "Marca de tiempo → fecha/hora",
json: "Formatear JSON",
jsonCompact: "Compactar JSON",
xml: "Formatear XML",
base64Encode: "Texto → Base64 (UTF-8)",
base64Decode: "Base64 → texto (UTF-8)",
urlEncode: "Codificar componente URL",
urlDecode: "Decodificar componente URL",
radix: "Conversión de base de enteros",
},
units: {
auto: "Automática (10/13 dígitos)",
seconds: "Segundos",
milliseconds: "Milisegundos",
},
errors: {
nullSource: "NULL no contiene texto para convertir.",
incomplete: "El valor completo aún no se ha cargado. Espera o vuelve a abrir los detalles de la celda.",
unsafeNumber: "Este número no se puede representar con precisión. Consúltalo como texto para conservarla.",
tooLarge: "Límite de conversión superado: {maxInput} caracteres de entrada, {maxOutput} de salida o {maxRadixDigits} para enteros.",
invalid: "No se puede convertir. Revisa el formato de entrada y las opciones.",
invalidTimestamp: "Introduce una marca de tiempo entera dentro del intervalo de fechas admitido.",
ambiguousUnit: "No se puede determinar la unidad. Selecciona segundos o milisegundos.",
invalidInteger: "Introduce un entero válido para la base de origen, sin prefijo.",
invalidBase64: "Introduce Base64 estándar con relleno que contenga texto UTF-8 válido.",
},
},
customType: {
kinds: {
base: "Base",
+42
View File
@@ -82,6 +82,48 @@ const consul = {
};
export default withEnglishFallback({
cellTransform: {
title: "Anteprima conversione",
description: "Converte il valore corrente per visualizzarlo o copiarlo. Il valore originale e la bozza restano invariati.",
format: "Conversione",
unit: "Unità del timestamp",
timezone: "Fuso orario",
pattern: "Formato data",
fromBase: "Base di origine",
toBase: "Base di destinazione",
convert: "Converti",
result: "Risultato",
copy: "Copia risultato",
radixHint: "Solo interi. Inserisci cifre senza prefissi 0x, 0o o 0b; è ammesso un segno facoltativo.",
urlHint: "Codifica dei componenti URL in UTF-8. La decodifica mantiene + come segno più.",
kinds: {
timestamp: "Timestamp → data/ora",
json: "Formatta JSON",
jsonCompact: "Compatta JSON",
xml: "Formatta XML",
base64Encode: "Testo → Base64 (UTF-8)",
base64Decode: "Base64 → testo (UTF-8)",
urlEncode: "Codifica componente URL",
urlDecode: "Decodifica componente URL",
radix: "Conversione di base degli interi",
},
units: {
auto: "Automatica (10/13 cifre)",
seconds: "Secondi",
milliseconds: "Millisecondi",
},
errors: {
nullSource: "NULL non contiene testo da convertire.",
incomplete: "Il valore completo non è ancora stato caricato. Attendi o riapri i dettagli della cella.",
unsafeNumber: "Questo numero non può essere rappresentato con precisione. Recuperalo come testo per conservarla.",
tooLarge: "Limite di conversione superato: {maxInput} caratteri in ingresso, {maxOutput} in uscita o {maxRadixDigits} per gli interi.",
invalid: "Impossibile convertire. Controlla il formato di ingresso e le opzioni.",
invalidTimestamp: "Inserisci un timestamp intero nell’intervallo di date supportato.",
ambiguousUnit: "Impossibile determinare l’unità. Seleziona secondi o millisecondi.",
invalidInteger: "Inserisci un intero valido per la base di origine, senza prefisso.",
invalidBase64: "Inserisci Base64 standard con padding contenente testo UTF-8 valido.",
},
},
customType: {
kinds: {
base: "Base",
+42
View File
@@ -83,6 +83,48 @@ const consul = {
};
export default withEnglishFallback({
cellTransform: {
title: "変換プレビュー",
description: "現在の値を変換して表示またはコピーします。元の値と編集中の内容は変更されません。",
format: "変換方法",
unit: "タイムスタンプの単位",
timezone: "タイムゾーン",
pattern: "日付形式",
fromBase: "変換元の基数",
toBase: "変換先の基数",
convert: "変換",
result: "変換結果",
copy: "結果をコピー",
radixHint: "整数のみ対応します。0x、0o、0b 接頭辞は不要です。正負の符号を使用できます。",
urlHint: "URL コンポーネントを UTF-8 でエンコード・デコードします。デコード時に + は空白に変換されません。",
kinds: {
timestamp: "タイムスタンプ → 日時",
json: "JSON 整形",
jsonCompact: "JSON 圧縮",
xml: "XML 整形",
base64Encode: "テキスト → Base64(UTF-8)",
base64Decode: "Base64 → テキスト(UTF-8)",
urlEncode: "URL コンポーネントのエンコード",
urlDecode: "URL コンポーネントのデコード",
radix: "整数の基数変換",
},
units: {
auto: "自動(10/13 桁)",
seconds: "秒",
milliseconds: "ミリ秒",
},
errors: {
nullSource: "NULL には変換できるテキストがありません。",
incomplete: "値全体がまだ読み込まれていません。読み込みを待つか、セルの詳細を開き直してください。",
unsafeNumber: "この数値は安全に表現できません。精度を保つためテキストとして取得してください。",
tooLarge: "変換上限を超えました。入力 {maxInput} 文字、出力 {maxOutput} 文字、整数入力 {maxRadixDigits} 文字です。",
invalid: "変換できません。入力形式と変換オプションを確認してください。",
invalidTimestamp: "対応する日付範囲内の整数タイムスタンプを入力してください。",
ambiguousUnit: "単位を判定できません。秒またはミリ秒を選択してください。",
invalidInteger: "選択した基数に対応する整数を、接頭辞なしで入力してください。",
invalidBase64: "有効な UTF-8 テキストを含む、標準のパディング付き Base64 を入力してください。",
},
},
customType: {
kinds: {
base: "基本型",
+42
View File
@@ -82,6 +82,48 @@ const consul = {
};
export default withEnglishFallback({
cellTransform: {
title: "변환 미리보기",
description: "현재 값을 변환하여 보거나 복사합니다. 원래 값과 편집 초안은 변경되지 않습니다.",
format: "변환 방식",
unit: "타임스탬프 단위",
timezone: "시간대",
pattern: "날짜 형식",
fromBase: "원본 진법",
toBase: "대상 진법",
convert: "변환",
result: "변환 결과",
copy: "결과 복사",
radixHint: "정수만 지원합니다. 0x, 0o, 0b 접두사 없이 입력하세요. 부호는 사용할 수 있습니다.",
urlHint: "URL 구성 요소를 UTF-8로 인코딩하거나 디코딩합니다. 디코딩 시 +를 공백으로 바꾸지 않습니다.",
kinds: {
timestamp: "타임스탬프 → 날짜/시간",
json: "JSON 서식 지정",
jsonCompact: "JSON 압축",
xml: "XML 서식 지정",
base64Encode: "텍스트 → Base64(UTF-8)",
base64Decode: "Base64 → 텍스트(UTF-8)",
urlEncode: "URL 구성 요소 인코딩",
urlDecode: "URL 구성 요소 디코딩",
radix: "정수 진법 변환",
},
units: {
auto: "자동(10/13자리)",
seconds: "초",
milliseconds: "밀리초",
},
errors: {
nullSource: "NULL에는 변환할 텍스트가 없습니다.",
incomplete: "전체 값을 아직 불러오지 못했습니다. 기다리거나 셀 상세 정보를 다시 여세요.",
unsafeNumber: "이 숫자는 안전하게 표현할 수 없습니다. 정밀도를 유지하려면 텍스트로 조회하세요.",
tooLarge: "변환 한도 초과: 입력 {maxInput}자, 출력 {maxOutput}자, 정수 입력 {maxRadixDigits}자입니다.",
invalid: "변환할 수 없습니다. 입력 형식과 변환 옵션을 확인하세요.",
invalidTimestamp: "지원하는 날짜 범위 내의 정수 타임스탬프를 입력하세요.",
ambiguousUnit: "단위를 확인할 수 없습니다. 초 또는 밀리초를 선택하세요.",
invalidInteger: "선택한 원본 진법에 맞는 정수를 접두사 없이 입력하세요.",
invalidBase64: "유효한 UTF-8 텍스트를 포함하는 표준 패딩 Base64를 입력하세요.",
},
},
customType: {
kinds: {
base: "기본형",
+42
View File
@@ -83,6 +83,48 @@ const consul = {
};
export default withEnglishFallback({
cellTransform: {
title: "Prévia da conversão",
description: "Converte o valor atual para visualizar ou copiar. O valor original e o rascunho permanecem inalterados.",
format: "Conversão",
unit: "Unidade do timestamp",
timezone: "Fuso horário",
pattern: "Formato de data",
fromBase: "Base de origem",
toBase: "Base de destino",
convert: "Converter",
result: "Resultado",
copy: "Copiar resultado",
radixHint: "Somente inteiros. Insira dígitos sem os prefixos 0x, 0o ou 0b; um sinal opcional é permitido.",
urlHint: "Codificação de componentes URL em UTF-8. A decodificação preserva + como sinal de adição.",
kinds: {
timestamp: "Timestamp → data/hora",
json: "Formatar JSON",
jsonCompact: "Compactar JSON",
xml: "Formatar XML",
base64Encode: "Texto → Base64 (UTF-8)",
base64Decode: "Base64 → texto (UTF-8)",
urlEncode: "Codificar componente URL",
urlDecode: "Decodificar componente URL",
radix: "Conversão de base de inteiros",
},
units: {
auto: "Automática (10/13 dígitos)",
seconds: "Segundos",
milliseconds: "Milissegundos",
},
errors: {
nullSource: "NULL não contém texto para converter.",
incomplete: "O valor completo ainda não foi carregado. Aguarde ou reabra os detalhes da célula.",
unsafeNumber: "Este número não pode ser representado com precisão. Consulte-o como texto para preservá-la.",
tooLarge: "Limite de conversão excedido: {maxInput} caracteres de entrada, {maxOutput} de saída ou {maxRadixDigits} para inteiros.",
invalid: "Não foi possível converter. Verifique o formato de entrada e as opções.",
invalidTimestamp: "Insira um timestamp inteiro dentro do intervalo de datas compatível.",
ambiguousUnit: "Não foi possível determinar a unidade. Selecione segundos ou milissegundos.",
invalidInteger: "Insira um inteiro válido para a base de origem, sem prefixo.",
invalidBase64: "Insira Base64 padrão com preenchimento contendo texto UTF-8 válido.",
},
},
customType: {
kinds: {
base: "Base",
+42
View File
@@ -82,6 +82,48 @@ const consul = {
};
export default withEnglishFallback({
cellTransform: {
title: "Dönüştürme önizlemesi",
description: "Geçerli değeri görüntülemek veya kopyalamak için dönüştürür. Özgün değer ve düzenleme taslağı değişmez.",
format: "Dönüştürme",
unit: "Zaman damgası birimi",
timezone: "Saat dilimi",
pattern: "Tarih biçimi",
fromBase: "Kaynak taban",
toBase: "Hedef taban",
convert: "Dönüştür",
result: "Sonuç",
copy: "Sonucu kopyala",
radixHint: "Yalnızca tam sayılar. 0x, 0o veya 0b öneki olmadan rakam girin; isteğe bağlı işaret kullanılabilir.",
urlHint: "URL bileşenleri UTF-8 ile kodlanır veya çözülür. Çözme işlemi + işaretini boşluğa dönüştürmez.",
kinds: {
timestamp: "Zaman damgası → tarih/saat",
json: "JSON biçimlendir",
jsonCompact: "JSON sıkıştır",
xml: "XML biçimlendir",
base64Encode: "Metin → Base64 (UTF-8)",
base64Decode: "Base64 → metin (UTF-8)",
urlEncode: "URL bileşenini kodla",
urlDecode: "URL bileşeninin kodunu çöz",
radix: "Tam sayı taban dönüşümü",
},
units: {
auto: "Otomatik (10/13 hane)",
seconds: "Saniye",
milliseconds: "Milisaniye",
},
errors: {
nullSource: "NULL dönüştürülebilecek metin içermez.",
incomplete: "Değerin tamamı henüz yüklenmedi. Bekleyin veya hücre ayrıntılarını yeniden açın.",
unsafeNumber: "Bu sayı güvenli biçimde temsil edilemiyor. Hassasiyeti korumak için metin olarak sorgulayın.",
tooLarge: "Dönüştürme sınırı aşıldı: girdi {maxInput} karakter, çıktı {maxOutput} karakter veya tam sayı girdisi {maxRadixDigits} karakter.",
invalid: "Dönüştürülemiyor. Girdi biçimini ve dönüştürme seçeneklerini kontrol edin.",
invalidTimestamp: "Desteklenen tarih aralığında bir tam sayı zaman damgası girin.",
ambiguousUnit: "Birim belirlenemiyor. Saniye veya milisaniyeyi seçin.",
invalidInteger: "Kaynak tabana uygun bir tam sayıyı öneksiz girin.",
invalidBase64: "Geçerli UTF-8 metni içeren standart dolgulu Base64 girin.",
},
},
customType: {
kinds: {
base: "Temel",
+42
View File
@@ -5,6 +5,48 @@ import { consul } from "./consulZhCN";
import { meilisearchManagementZhCN } from "./meilisearchManagement";
export default withEnglishFallback({
cellTransform: {
title: "转换预览",
description: "将当前内容转换后查看或复制,不改变原值和编辑草稿。",
format: "转换方式",
unit: "时间戳单位",
timezone: "时区",
pattern: "日期格式",
fromBase: "原进制",
toBase: "目标进制",
convert: "转换",
result: "转换结果",
copy: "复制结果",
radixHint: "仅支持整数,请勿输入 0x、0o 或 0b 前缀,可使用正负号。",
urlHint: "按 URL 组件进行 UTF-8 编解码,解码时保留加号,不将其替换为空格。",
kinds: {
timestamp: "时间戳 → 日期时间",
json: "JSON 格式化",
jsonCompact: "JSON 压缩",
xml: "XML 格式化",
base64Encode: "文本 → Base64(UTF-8)",
base64Decode: "Base64 → 文本(UTF-8)",
urlEncode: "URL 组件编码",
urlDecode: "URL 组件解码",
radix: "整数进制转换",
},
units: {
auto: "自动(10/13 位)",
seconds: "秒",
milliseconds: "毫秒",
},
errors: {
nullSource: "NULL 没有可转换的文本。",
incomplete: "完整值尚未加载,请等待加载完成或重新打开单元格详情。",
unsafeNumber: "该数值无法安全表示,请以文本形式查询以保留精度。",
tooLarge: "超过转换限制:输入 {maxInput} 字符、输出 {maxOutput} 字符,整数输入 {maxRadixDigits} 字符。",
invalid: "无法转换,请检查输入格式和转换选项。",
invalidTimestamp: "请输入有效日期范围内的整数时间戳。",
ambiguousUnit: "无法确定时间戳单位,请手动选择秒或毫秒。",
invalidInteger: "请输入符合原进制的整数,不要包含进制前缀。",
invalidBase64: "请输入带标准填充的 Base64,且解码内容必须为有效 UTF-8 文本。",
},
},
redisGrouping,
customType: {
kinds: {
+42
View File
@@ -82,6 +82,48 @@ const consul = {
};
export default withEnglishFallback({
cellTransform: {
title: "轉換預覽",
description: "將目前內容轉換後檢視或複製,不改變原值和編輯草稿。",
format: "轉換方式",
unit: "時間戳記單位",
timezone: "時區",
pattern: "日期格式",
fromBase: "原進位",
toBase: "目標進位",
convert: "轉換",
result: "轉換結果",
copy: "複製結果",
radixHint: "僅支援整數,請勿輸入 0x、0o 或 0b 前綴,可使用正負號。",
urlHint: "按 URL 元件進行 UTF-8 編解碼,解碼時保留加號,不將其替換為空格。",
kinds: {
timestamp: "時間戳記 → 日期時間",
json: "JSON 格式化",
jsonCompact: "JSON 壓縮",
xml: "XML 格式化",
base64Encode: "文字 → Base64(UTF-8)",
base64Decode: "Base64 → 文字(UTF-8)",
urlEncode: "URL 元件編碼",
urlDecode: "URL 元件解碼",
radix: "整數進位轉換",
},
units: {
auto: "自動(10/13 位)",
seconds: "秒",
milliseconds: "毫秒",
},
errors: {
nullSource: "NULL 沒有可轉換的文字。",
incomplete: "完整值尚未載入,請等待載入完成或重新開啟儲存格詳細資料。",
unsafeNumber: "該數值無法安全表示,請以文字形式查詢以保留精確度。",
tooLarge: "超過轉換限制:輸入 {maxInput} 字元、輸出 {maxOutput} 字元,整數輸入 {maxRadixDigits} 字元。",
invalid: "無法轉換,請檢查輸入格式和轉換選項。",
invalidTimestamp: "請輸入有效日期範圍內的整數時間戳記。",
ambiguousUnit: "無法確定時間戳記單位,請手動選擇秒或毫秒。",
invalidInteger: "請輸入符合原進位的整數,不要包含進位前綴。",
invalidBase64: "請輸入帶標準填補的 Base64,且解碼內容必須為有效 UTF-8 文字。",
},
},
redisGrouping,
customType: {
kinds: {
@@ -0,0 +1,114 @@
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
import { formatXmlSource } from "@/lib/common/xmlFormat";
import { applyColumnFormatter, DataGridDateTimePatterns } from "@/lib/dataGrid/columnFormatter";
export const CELL_TRANSFORM_MAX_INPUT = 50_000;
export const CELL_TRANSFORM_MAX_OUTPUT = 200_000;
export const CELL_TRANSFORM_MAX_RADIX_DIGITS = 4_096;
export const CELL_TRANSFORM_KINDS = ["timestamp", "json", "jsonCompact", "xml", "base64Encode", "base64Decode", "urlEncode", "urlDecode", "radix"] as const;
export type CellTransformKind = (typeof CELL_TRANSFORM_KINDS)[number];
export type CellTransformError = "tooLarge" | "invalid" | "invalidTimestamp" | "ambiguousUnit" | "invalidInteger" | "invalidBase64";
export interface CellTransformOptions {
kind: CellTransformKind;
unit?: "auto" | "seconds" | "milliseconds";
timezone?: string;
pattern?: string;
fromBase?: number;
toBase?: number;
}
export type CellTransformResult = { ok: true; text: string; unit?: "seconds" | "milliseconds" } | { ok: false; error: CellTransformError };
function fail(error: CellTransformError): never {
throw new TransformError(error);
}
class TransformError extends Error {
constructor(readonly code: CellTransformError) {
super(code);
}
}
/** Pure, opt-in text conversion. Never feed this result into the cell commit path. */
export function transformCellValue(source: string, options: CellTransformOptions): CellTransformResult {
if (source.length > CELL_TRANSFORM_MAX_INPUT) return { ok: false, error: "tooLarge" };
try {
let text: string;
let unit: "seconds" | "milliseconds" | undefined;
switch (options.kind) {
case "timestamp": {
const input = source.trim();
if (!/^[+-]?\d+$/.test(input)) fail("invalidTimestamp");
unit = options.unit === "seconds" || options.unit === "milliseconds" ? options.unit : undefined;
if (!unit) {
const digits = input.replace(/^[+-]/, "").length;
if (digits !== 10 && digits !== 13) fail("ambiguousUnit");
unit = digits === 10 ? "seconds" : "milliseconds";
}
const value = Number(input);
const milliseconds = unit === "seconds" ? value * 1000 : value;
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(milliseconds) || Math.abs(milliseconds) > 8_640_000_000_000_000) fail("invalidTimestamp");
const timezone = options.timezone === undefined ? "UTC" : options.timezone.trim();
new Intl.DateTimeFormat("en", { timeZone: timezone }).format(0);
const pattern = options.pattern || "YYYY-MM-DD HH:mm:ss";
if (!DataGridDateTimePatterns.includes(pattern)) fail("invalidTimestamp");
text = applyColumnFormatter(value, { kind: "datetime", unit, timezone, pattern });
// Column formatters intentionally fall back to the raw value. A conversion
// must surface failure instead of presenting that fallback as a result.
if (text === String(value) || text === "Invalid Date") fail("invalidTimestamp");
break;
}
case "json":
text = formatJsonSource(source, 2);
break;
case "jsonCompact":
text = formatJsonSource(source);
break;
case "xml":
text = formatXmlSource(source);
break;
case "urlEncode":
text = encodeURIComponent(source);
break;
case "urlDecode":
text = decodeURIComponent(source);
break;
case "base64Encode": {
// Reject lone surrogates rather than silently replacing them during UTF-8 encoding.
encodeURIComponent(source);
const bytes = new TextEncoder().encode(source);
text = btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""));
break;
}
case "base64Decode": {
const input = source.replace(/\s/g, "");
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(input)) fail("invalidBase64");
const binary = atob(input);
if (btoa(binary) !== input) fail("invalidBase64");
try {
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Uint8Array.from(binary, (character) => character.charCodeAt(0)));
} catch {
fail("invalidBase64");
}
break;
}
case "radix": {
const from = options.fromBase ?? 10;
const to = options.toBase ?? 16;
if (![2, 8, 10, 16].includes(from) || ![2, 8, 10, 16].includes(to)) fail("invalidInteger");
const input = source.trim();
if (input.length > CELL_TRANSFORM_MAX_RADIX_DIGITS) fail("tooLarge");
const sign = input.startsWith("-") ? -1n : 1n;
const digits = input.replace(/^[+-]/, "");
const valid = from === 2 ? /^[01]+$/ : from === 8 ? /^[0-7]+$/ : from === 10 ? /^\d+$/ : /^[0-9a-f]+$/i;
if (!valid.test(digits)) fail("invalidInteger");
const prefix = from === 2 ? "0b" : from === 8 ? "0o" : from === 16 ? "0x" : "";
text = (sign * BigInt(prefix + digits)).toString(to);
break;
}
default:
return { ok: false, error: "invalid" };
}
return text.length > CELL_TRANSFORM_MAX_OUTPUT ? { ok: false, error: "tooLarge" } : { ok: true, text, ...(unit ? { unit } : {}) };
} catch (error) {
return { ok: false, error: error instanceof TransformError ? error.code : "invalid" };
}
}
@@ -19,6 +19,8 @@ export interface DataGridCellDetail {
displayValue: string;
displayValuePreview: string;
isValuePreviewTruncated: boolean;
/** True only while the backend value is incomplete, not when the UI clips its preview. */
isSourceTruncated?: boolean;
imagePreviewUrl: string | null;
length: number;
isNull?: boolean;
@@ -119,6 +121,7 @@ export function buildDataGridCellDetail(options: BuildDataGridCellDetailOptions)
rawValuePreview,
displayValue,
displayValuePreview,
isSourceTruncated: options.isValuePreviewTruncated === true,
isValuePreviewTruncated: options.isValuePreviewTruncated === true || rawValuePreview.length < rawValue.length || displayValuePreview.length < displayValue.length,
imagePreviewUrl: cellImagePreviewUrl(value, type, {
binary: options.includeBinaryImagePreview !== false,