19 files changed
+903
-4
No files matched your search
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "基本型",
|
||||
|
||||
@@ -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: "기본형",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user