diff --git a/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue b/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue index 39abb4c8d5..c223a43cb1 100644 --- a/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue +++ b/apps/desktop/src/components/grid/DataGridCellDetailDialog.vue @@ -1,4 +1,5 @@ + + + + + {{ t("grid.binaryTextPreview") }} + + + + {{ t("grid.binaryTextPreviewTitle") }} + {{ t("grid.binaryTextPreviewDescription") }} + + + {{ t("grid.binaryTextPreviewEncoding") }} + + + {{ t(`grid.binaryTextPreviewEncodings.${item}`) }} + + + {{ t("grid.binaryTextPreviewByteCount", { count: byteLength }) }} + + {{ t(errorKey) }} + + + {{ t("grid.binaryTextPreviewResult") }} + {{ t("grid.binaryTextPreviewCopy") }} + + + + {{ t("grid.binaryTextPreviewOriginalHint") }} + + + diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index 84405205da..6c6403ea20 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ renderWkt: vi.fn(), panelCancel: vi.fn(), panelOpenSearch: vi.fn(), + copyToClipboard: vi.fn(), + toast: vi.fn(), })); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); @@ -53,7 +55,15 @@ vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHos vi.mock("@/components/ui/input", async () => ({ Input: (await import("./vueHostHarness")).createPassthroughStub("Input", "input") })); vi.mock("@/components/ui/dialog", async () => { const { createPassthroughStub } = await import("./vueHostHarness"); - return { Dialog: createPassthroughStub("Dialog"), DialogContent: createPassthroughStub("DialogContent"), DialogFooter: createPassthroughStub("DialogFooter"), DialogHeader: createPassthroughStub("DialogHeader"), DialogTitle: createPassthroughStub("DialogTitle") }; + return { + Dialog: createPassthroughStub("Dialog"), + DialogContent: createPassthroughStub("DialogContent"), + DialogDescription: createPassthroughStub("DialogDescription"), + DialogFooter: createPassthroughStub("DialogFooter"), + DialogHeader: createPassthroughStub("DialogHeader"), + DialogTitle: createPassthroughStub("DialogTitle"), + DialogTrigger: createPassthroughStub("DialogTrigger"), + }; }); vi.mock("@/components/ui/dropdown-menu", async () => { const { createPassthroughStub } = await import("./vueHostHarness"); @@ -82,6 +92,8 @@ vi.mock("@/composables/useCellDetailEditor", () => ({ useCellDetailEditor: () => 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 }) })); vi.mock("@/lib/dataGrid/geometryPreview", () => ({ isHexGeometry: () => false, renderWktOnCanvas: mocks.renderWkt })); +vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); vi.mock("@/composables/useDataGridCellDetail", async () => { const { ref } = await import("vue"); return { @@ -106,6 +118,8 @@ import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue"; const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); const dataGridCellDetailEditSource = readFileSync("apps/desktop/src/composables/useDataGridCellDetailEdit.ts", "utf8"); const cellDetailPanelSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailPanel.vue", "utf8"); +const cellDetailDialogSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailDialog.vue", "utf8"); +const binaryTextPreviewSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailTextPreview.vue", "utf8"); const cellDetailHeaderSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailHeader.vue", "utf8"); const globalsCss = readFileSync("apps/desktop/src/styles/globals.css", "utf8"); @@ -1540,6 +1554,164 @@ describe("cell detail surfaces", () => { expect(hostText(panel.root)).toContain("0x89504e470d0a1a0a"); }); + // issue #9505:GaussDB(ZenithDriver)的 BLOB 已经以 `0x` 到达前端,缺的是详情里显式、只读的文本查看入口。 + it("offers an explicit read-only binary text preview in the dialog and the panel", () => { + const blobDetail = detail({ + type: "BLOB", + value: "0x5b5b68656164657273203d207b7d", + rawValue: "0x5b5b68656164657273203d207b7d", + rawValuePreview: "0x5b5b68656164657273203d207b7d", + displayValue: "BLOB [14 bytes]", + displayValuePreview: "BLOB [14 bytes]", + formattedJson: "", + }); + const dialog = mountComponent(DataGridCellDetailDialog, { + open: true, + detail: blobDetail, + typeColorClass: () => "", + openImagePreview: vi.fn(), + copyText: vi.fn(), + canDownloadBinaryValue: () => true, + downloadBinaryValue: vi.fn(), + canImportBinaryValue: () => false, + importBinaryValue: vi.fn(), + databaseType: "gaussdb", + }); + const panel = mountComponent(DataGridCellDetailPanel, { + detail: blobDetail, + panelIsBottom: true, + metadataCollapsed: false, + valueFillsHeight: false, + editing: false, + sideJsonView: false, + showCompactJson: false, + canCompactJson: false, + typeColorClass: () => "", + canDownloadBinaryValue: () => true, + downloadBinaryValue: vi.fn(), + canImportBinaryValue: () => false, + importBinaryValue: vi.fn(), + openImagePreview: vi.fn(), + canCopySqlCondition: () => true, + databaseType: "gaussdb", + }); + + for (const mounted of [dialog, panel]) { + // canonical 值仍是 0x 十六进制,解码文本只出现在只读预览里。 + expect(hostText(mounted.root)).toContain("0x5b5b68656164657273203d207b7d"); + expect(hostText(mounted.root)).toContain("grid.binaryTextPreview"); + // binary 列不再渲染 timestamp/radix 这类对 hex 无意义的文本转换。 + expect(findAll(mounted.root, (node) => node.props["data-stub"] === "DataGridValueTransform")).toHaveLength(0); + expect(findOne(mounted.root, (node) => node.type === "textarea").value).toBe("[[headers = {}"); + } + }); + + it("re-decodes the binary text preview with GBK only after the user picks it", async () => { + const panel = mountComponent(DataGridCellDetailPanel, { + detail: detail({ type: "BLOB", value: "0xd6d0cec4", rawValue: "0xd6d0cec4", rawValuePreview: "0xd6d0cec4", displayValue: "BLOB [4 bytes]", displayValuePreview: "BLOB [4 bytes]", formattedJson: "" }), + panelIsBottom: true, + metadataCollapsed: false, + valueFillsHeight: false, + editing: false, + sideJsonView: false, + showCompactJson: false, + canCompactJson: false, + typeColorClass: () => "", + canDownloadBinaryValue: () => true, + downloadBinaryValue: vi.fn(), + canImportBinaryValue: () => false, + importBinaryValue: vi.fn(), + openImagePreview: vi.fn(), + canCopySqlCondition: () => true, + databaseType: "gaussdb", + }); + + // 默认 UTF-8:GBK 字节严格解码失败时给出失败提示,而不是替换字符。 + expect(hostText(panel.root)).toContain("grid.binaryTextPreviewErrors.undecodable"); + dispatch( + findOne(panel.root, (node) => node.type === "button" && hostText(node).trim() === "grid.binaryTextPreviewEncodings.gbk"), + "click", + ); + await nextTick(); + + expect(hostText(panel.root)).not.toContain("grid.binaryTextPreviewErrors.undecodable"); + expect(findOne(panel.root, (node) => node.type === "textarea").value).toBe("中文"); + }); + + it("keeps the value transform entry for non-binary detail cells", () => { + const panel = mountComponent(DataGridCellDetailPanel, { + detail: detail({ type: "VARCHAR(32)", value: "hello", rawValue: "hello", rawValuePreview: "hello", displayValue: "hello", displayValuePreview: "hello", formattedJson: "" }), + panelIsBottom: true, + metadataCollapsed: false, + valueFillsHeight: false, + editing: false, + sideJsonView: false, + showCompactJson: false, + canCompactJson: false, + typeColorClass: () => "", + canDownloadBinaryValue: () => false, + downloadBinaryValue: vi.fn(), + canImportBinaryValue: () => false, + importBinaryValue: vi.fn(), + openImagePreview: vi.fn(), + canCopySqlCondition: () => true, + databaseType: "mysql", + }); + + expect(findAll(panel.root, (node) => node.props["data-stub"] === "DataGridValueTransform")).toHaveLength(1); + expect(hostText(panel.root)).not.toContain("grid.binaryTextPreviewTitle"); + expect(findAll(panel.root, (node) => node.type === "textarea")).toHaveLength(0); + }); + + it("keeps the binary text preview out of the copy-back and edit paths", async () => { + const copyValue = vi.fn(); + const startEdit = vi.fn(); + const panel = mountComponent(DataGridCellDetailPanel, { + detail: detail({ type: "BLOB", value: "0x48656c6c6f", rawValue: "0x48656c6c6f", rawValuePreview: "0x48656c6c6f", displayValue: "BLOB [5 bytes]", displayValuePreview: "BLOB [5 bytes]", formattedJson: "" }), + panelIsBottom: true, + metadataCollapsed: false, + valueFillsHeight: false, + editing: false, + sideJsonView: false, + showCompactJson: false, + canCompactJson: false, + typeColorClass: () => "", + canDownloadBinaryValue: () => true, + downloadBinaryValue: vi.fn(), + canImportBinaryValue: () => false, + importBinaryValue: vi.fn(), + openImagePreview: vi.fn(), + canCopySqlCondition: () => true, + databaseType: "gaussdb", + onCopyValue: copyValue, + onStartEdit: startEdit, + }); + + dispatch( + findOne(panel.root, (node) => node.type === "button" && hostText(node).trim() === "grid.binaryTextPreviewCopy"), + "click", + ); + await nextTick(); + + expect(mocks.copyToClipboard).toHaveBeenCalledWith("Hello"); + expect(mocks.toast).toHaveBeenCalledWith("grid.cellValueCopied", 2000); + // 预览只读:不触发栅格复制/编辑,details 预览仍展示未修改的 canonical hex。 + expect(copyValue).not.toHaveBeenCalled(); + expect(startEdit).not.toHaveBeenCalled(); + expect(hostText(panel.root)).toContain("0x48656c6c6f"); + expect(findOne(panel.root, (node) => node.type === "textarea").value).toBe("Hello"); + }); + + it("keeps the binary text preview component strictly read-only and shared by both surfaces", () => { + expect(binaryTextPreviewSource).toContain("binaryCellTextPreview(props.value"); + // 不发出任何编辑/提交事件,也不给 detail.value / rawValue 赋值。 + expect(binaryTextPreviewSource).not.toContain("defineEmits"); + expect(binaryTextPreviewSource).not.toMatch(/(?:props|detail)\.(?:value|rawValue)\s*=/); + expect(cellDetailDialogSource).toMatch(/ { const copyText = vi.fn(); const edit = vi.fn(); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index b5358c7374..5bb9e974b4 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -2234,6 +2234,23 @@ export default withEnglishFallback({ utf8: "Dekodlaşdırılmış mətn (UTF-8)", gbk: "Dekodlaşdırılmış mətn (GBK)", }, + binaryTextPreview: "Mətn kimi bax", + binaryTextPreviewTitle: "İkili mətn önizləməsi", + binaryTextPreviewDescription: "0x onaltılıq baytları seçilmiş simvol dəsti ilə ciddi şəkildə dekodlaşdırır. Yalnız baxış üçündür; ilkin dəyər dəyişmir.", + binaryTextPreviewEncoding: "Simvol dəsti", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} bayt", + binaryTextPreviewResult: "Dekodlaşdırılmış mətn", + binaryTextPreviewCopy: "Nəticəni kopyala", + binaryTextPreviewOriginalHint: "İlkin dəyər 0x onaltılıq kimi qalır; bu önizləmə hüceyrəni və ya verilənlər bazasını dəyişmir.", + binaryTextPreviewErrors: { + notBinary: "Bu dəyər tam ikili dəyər olmadığı üçün dekodlaşdırıla bilməz.", + incomplete: "Tam dəyər hələ yüklənməyib; natamam önizləməni dekodlaşdırmaq yanıltıcı olar.", + undecodable: "Ciddi dekodlaşdırma alınmadı: bu mətn deyil, real ikili məlumat (şəkil, arxiv və ya yanlış bayt ardıcıllığı) kimi görünür.", + }, copyRowTsv: "Sətri kopyala (TSV)", copyColumnValues: "Sütunu kopyala (JSON)", copyColumnTsv: "Sütunu kopyala (TSV)", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 2250f11d40..494636ff4d 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2394,6 +2394,23 @@ export default { utf8: "Decoded text (UTF-8)", gbk: "Decoded text (GBK)", }, + binaryTextPreview: "View as text", + binaryTextPreviewTitle: "Binary text preview", + binaryTextPreviewDescription: "Strictly decodes the 0x hex bytes with the selected charset. Read-only preview; the original value is not modified.", + binaryTextPreviewEncoding: "Charset", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} bytes", + binaryTextPreviewResult: "Decoded text", + binaryTextPreviewCopy: "Copy result", + binaryTextPreviewOriginalHint: "The original value stays as 0x hex; this preview does not modify the cell or the database.", + binaryTextPreviewErrors: { + notBinary: "This value is not a complete binary value, so it cannot be decoded.", + incomplete: "The full value has not been loaded yet; decoding an incomplete preview would be misleading.", + undecodable: "Strict decoding failed: this looks like real binary data (image, archive or invalid byte sequence), not text.", + }, copyRowTsv: "Copy Row (TSV)", copyColumnValues: "Copy Column (JSON)", copyColumnTsv: "Copy Column (TSV)", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 089783a6d5..e692ab87e7 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2222,6 +2222,23 @@ export default withEnglishFallback({ utf8: "Texto decodificado (UTF-8)", gbk: "Texto decodificado (GBK)", }, + binaryTextPreview: "Ver como texto", + binaryTextPreviewTitle: "Vista de texto binaria", + binaryTextPreviewDescription: "Decodifica de forma estricta los bytes hexadecimales 0x con el juego de caracteres elegido. Solo lectura; el valor original no cambia.", + binaryTextPreviewEncoding: "Juego de caracteres", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} bytes", + binaryTextPreviewResult: "Texto decodificado", + binaryTextPreviewCopy: "Copiar resultado", + binaryTextPreviewOriginalHint: "El valor original se mantiene en hexadecimal 0x; esta vista previa no modifica la celda ni la base de datos.", + binaryTextPreviewErrors: { + notBinary: "Este valor no es un binario completo, por lo que no se puede decodificar.", + incomplete: "El valor completo aún no se ha cargado; decodificar una vista parcial sería engañoso.", + undecodable: "La decodificación estricta falló: parece un dato binario real (imagen, archivo comprimido o secuencia de bytes no válida), no texto.", + }, copyRowTsv: "Copiar fila (TSV)", copyColumnValues: "Copiar columna (JSON)", copyColumnTsv: "Copiar columna (TSV)", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 88cb9aea7f..8b3dc4dd5a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2220,6 +2220,23 @@ export default withEnglishFallback({ utf8: "Testo decodificato (UTF-8)", gbk: "Testo decodificato (GBK)", }, + binaryTextPreview: "Visualizza come testo", + binaryTextPreviewTitle: "Anteprima testo binaria", + binaryTextPreviewDescription: "Decodifica in modo rigoroso i byte esadecimali 0x con il set di caratteri scelto. Solo lettura; il valore originale non viene modificato.", + binaryTextPreviewEncoding: "Set di caratteri", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} byte", + binaryTextPreviewResult: "Testo decodificato", + binaryTextPreviewCopy: "Copia risultato", + binaryTextPreviewOriginalHint: "Il valore originale resta in esadecimale 0x; questa anteprima non modifica la cella né il database.", + binaryTextPreviewErrors: { + notBinary: "Questo valore non è un binario completo, quindi non può essere decodificato.", + incomplete: "Il valore completo non è ancora stato caricato; decodificare un'anteprima parziale sarebbe fuorviante.", + undecodable: "Decodifica rigorosa non riuscita: sembra un dato binario reale (immagine, archivio o sequenza di byte non valida), non testo.", + }, copyRowTsv: "Copia Riga (TSV)", copyColumnValues: "Copia Colonna (JSON)", copyColumnTsv: "Copia Colonna (TSV)", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 16fd12d3cc..251808d39f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2232,6 +2232,23 @@ export default withEnglishFallback({ utf8: "デコード済みテキスト (UTF-8)", gbk: "デコード済みテキスト (GBK)", }, + binaryTextPreview: "テキストとして表示", + binaryTextPreviewTitle: "バイナリのテキストプレビュー", + binaryTextPreviewDescription: "選択した文字コードで 0x の 16 進数バイトを厳密にデコードします。表示専用で、元の値は変更されません。", + binaryTextPreviewEncoding: "文字コード", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} バイト", + binaryTextPreviewResult: "デコード結果", + binaryTextPreviewCopy: "結果をコピー", + binaryTextPreviewOriginalHint: "元の値は 0x の 16 進数のまま保持され、このプレビューでセルやデータベースは変更されません。", + binaryTextPreviewErrors: { + notBinary: "この値は完全なバイナリではないため、デコードできません。", + incomplete: "完全な値がまだ読み込まれていないため、不完全なプレビューをデコードすると誤解を招きます。", + undecodable: "厳密なデコードに失敗しました。画像・アーカイブ・不正なバイト列など、テキストではないバイナリデータの可能性があります。", + }, copyRowTsv: "行をコピー (TSV)", copyColumnValues: "列をコピー (JSON)", copyColumnTsv: "列をコピー (TSV)", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index dbb7dfe9ca..62404c0385 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2215,6 +2215,23 @@ export default withEnglishFallback({ utf8: "디코딩된 텍스트 (UTF-8)", gbk: "디코딩된 텍스트 (GBK)", }, + binaryTextPreview: "텍스트로 보기", + binaryTextPreviewTitle: "바이너리 텍스트 미리보기", + binaryTextPreviewDescription: "선택한 문자 집합으로 0x 16진수 바이트를 엄격하게 디코딩합니다. 읽기 전용이며 원본 값은 변경되지 않습니다.", + binaryTextPreviewEncoding: "문자 집합", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count}바이트", + binaryTextPreviewResult: "디코딩 결과", + binaryTextPreviewCopy: "결과 복사", + binaryTextPreviewOriginalHint: "원본 값은 0x 16진수로 유지되며, 이 미리보기는 셀이나 데이터베이스를 변경하지 않습니다.", + binaryTextPreviewErrors: { + notBinary: "이 값은 완전한 바이너리 값이 아니어서 디코딩할 수 없습니다.", + incomplete: "전체 값이 아직 로드되지 않았습니다. 불완전한 미리보기를 디코딩하면 오해를 줄 수 있습니다.", + undecodable: "엄격한 디코딩에 실패했습니다. 이미지, 압축 파일, 잘못된 바이트열 등 실제 바이너리 데이터일 수 있습니다.", + }, copyRowTsv: "행 복사 (TSV)", copyColumnValues: "컬럼 복사 (JSON)", copyColumnTsv: "컬럼 복사 (TSV)", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1516f8b705..3de65eefc3 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2222,6 +2222,23 @@ export default withEnglishFallback({ utf8: "Texto decodificado (UTF-8)", gbk: "Texto decodificado (GBK)", }, + binaryTextPreview: "Ver como texto", + binaryTextPreviewTitle: "Pré-visualização de texto binário", + binaryTextPreviewDescription: "Decodifica estritamente os bytes hexadecimais 0x com o conjunto de caracteres escolhido. Somente leitura; o valor original não é alterado.", + binaryTextPreviewEncoding: "Conjunto de caracteres", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} bytes", + binaryTextPreviewResult: "Texto decodificado", + binaryTextPreviewCopy: "Copiar resultado", + binaryTextPreviewOriginalHint: "O valor original permanece em hexadecimal 0x; esta pré-visualização não altera a célula nem o banco de dados.", + binaryTextPreviewErrors: { + notBinary: "Este valor não é um binário completo, então não pode ser decodificado.", + incomplete: "O valor completo ainda não foi carregado; decodificar uma pré-visualização incompleta seria enganoso.", + undecodable: "A decodificação estrita falhou: parece um dado binário real (imagem, arquivo compactado ou sequência de bytes inválida), não texto.", + }, copyRowTsv: "Copiar Linha (TSV)", copyColumnValues: "Copiar Coluna (JSON)", copyColumnTsv: "Copiar Coluna (TSV)", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 88ee39932c..4aaa6d8658 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -2229,6 +2229,23 @@ export default withEnglishFallback({ utf8: "Çözülmüş metin (UTF-8)", gbk: "Çözülmüş metin (GBK)", }, + binaryTextPreview: "Metin olarak görüntüle", + binaryTextPreviewTitle: "İkili metin önizlemesi", + binaryTextPreviewDescription: "0x onaltılık baytları seçilen karakter kümesiyle katı biçimde çözer. Salt okunur; özgün değer değişmez.", + binaryTextPreviewEncoding: "Karakter kümesi", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} bayt", + binaryTextPreviewResult: "Çözülen metin", + binaryTextPreviewCopy: "Sonucu kopyala", + binaryTextPreviewOriginalHint: "Özgün değer 0x onaltılık biçiminde kalır; bu önizleme hücreyi veya veritabanını değiştirmez.", + binaryTextPreviewErrors: { + notBinary: "Bu değer tam bir ikili değer olmadığı için çözülemez.", + incomplete: "Tam değer henüz yüklenmedi; eksik bir önizlemeyi çözmek yanıltıcı olur.", + undecodable: "Katı çözme başarısız oldu: bu metin değil, gerçek ikili veri (görsel, arşiv veya geçersiz bayt dizisi) gibi görünüyor.", + }, copyRowTsv: "Satırı Kopyala (TSV)", copyColumnValues: "Sütunu Kopyala (JSON)", copyColumnTsv: "Sütunu Kopyala (TSV)", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index f6881ec132..da84395172 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2309,6 +2309,23 @@ export default withEnglishFallback({ utf8: "解码文本 (UTF-8)", gbk: "解码文本 (GBK)", }, + binaryTextPreview: "以文本查看", + binaryTextPreviewTitle: "二进制文本预览", + binaryTextPreviewDescription: "按所选字符集严格解码 0x 十六进制内容,仅用于查看,不修改原值。", + binaryTextPreviewEncoding: "字符集", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} 字节", + binaryTextPreviewResult: "解码结果", + binaryTextPreviewCopy: "复制结果", + binaryTextPreviewOriginalHint: "原始值仍以 0x 十六进制保存,本次预览不会修改单元格或数据库。", + binaryTextPreviewErrors: { + notBinary: "当前值不是完整的二进制内容,无法解码。", + incomplete: "完整值尚未加载,解码不完整的预览会产生误导。", + undecodable: "严格解码失败:该内容更像真实二进制数据(图片、压缩包或非法字节序列),不是文本。", + }, copyRowTsv: "复制行 (TSV)", copyColumnValues: "复制列 (JSON)", copyColumnTsv: "复制列 (TSV)", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 584f8c8d03..eeb956a990 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2228,6 +2228,23 @@ export default withEnglishFallback({ utf8: "文字編碼 (UTF-8)", gbk: "文字編碼 (GBK)", }, + binaryTextPreview: "以文字檢視", + binaryTextPreviewTitle: "二進位文字預覽", + binaryTextPreviewDescription: "依所選字元集嚴格解碼 0x 十六進位內容,僅供檢視,不修改原值。", + binaryTextPreviewEncoding: "字元集", + binaryTextPreviewEncodings: { + utf8: "UTF-8", + gbk: "GBK", + }, + binaryTextPreviewByteCount: "{count} 位元組", + binaryTextPreviewResult: "解碼結果", + binaryTextPreviewCopy: "複製結果", + binaryTextPreviewOriginalHint: "原始值仍以 0x 十六進位保存,本次預覽不會修改儲存格或資料庫。", + binaryTextPreviewErrors: { + notBinary: "目前值不是完整的二進位內容,無法解碼。", + incomplete: "完整值尚未載入,解碼不完整的預覽會造成誤導。", + undecodable: "嚴格解碼失敗:此內容更像真實二進位資料(圖片、壓縮檔或非法位元組序列),不是文字。", + }, copyRowTsv: "複製整筆 (TSV)", copyColumnValues: "複製欄 (JSON)", copyColumnTsv: "複製欄 (TSV)", diff --git a/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts b/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts index 93083d17fd..f747463cca 100644 --- a/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts +++ b/apps/desktop/src/lib/dataGrid/binaryCellDownload.ts @@ -221,6 +221,51 @@ export function binaryCellUtf8Text(value: unknown, columnType?: string, database return binaryCellUtf8TextBytes(bytes, columnType); } +/** 单元格详情里「以文本查看」可选的字符集。 */ +export type BinaryCellTextEncoding = "utf8" | "gbk"; + +export const BINARY_CELL_TEXT_ENCODINGS: BinaryCellTextEncoding[] = ["utf8", "gbk"]; + +/** + * 显式文本预览(「以文本查看」)的失败原因,调用方据此给出不同文案: + * + * - `notBinary`:值不是 binary canonical form(含 hex 已被截断、列类型非 binary 的情况); + * - `incomplete`:大值预览闸门截断了原始值,bytes 不完整,继续解码会把残缺内容当成文本; + * - `undecodable`:bytes 完整,但严格解码失败(非法序列,或图片/压缩包等二进制的控制字符)。 + */ +export type BinaryCellTextPreviewError = "notBinary" | "incomplete" | "undecodable"; + +export type BinaryCellTextPreviewResult = { ok: true; text: string; encoding: BinaryCellTextEncoding; byteLength: number } | { ok: false; error: BinaryCellTextPreviewError }; + +/** + * issue #9505:把 binary canonical value(`0x`)按用户显式选择的字符集解码成**只读**文本。 + * + * 与「下载为 UTF-8/GBK」共用同一套 bytes primitive({@link parseBinaryCellBytes} + + * `binaryPreviewBytes`),但刻意不复用 `binaryCellDownloadPayload` 的宽松 `TextDecoder`: + * 预览必须走严格解码(见 {@link binaryCellUtf8TextBytes} / {@link printableGbkText}), + * 否则图片头等真实二进制会被替换字符伪装成「正常文本」。 + * + * 与 `binaryCellUtf8Text` 的区别是**不做数据库白名单**:MySQL BLOB 的自动文本预览只对 mysql + * 连接开启(显示/编辑语义需要一致),而这里由用户在单元格详情里显式点击,属于只读 presentation, + * 因此任何被 `isBinaryCellColumnType()` 识别的 binary 列(BLOB/TINYBLOB/MEDIUMBLOB/LONGBLOB、 + * BINARY/VARBINARY、BYTEA、BYTES、IMAGE、RAW/LONG RAW…)都可查看,且不参与解码闸门之外的任何写回路径。 + * 入口可见性完全复用 `isBinaryCellColumnType()`,本次不为未知类型名称放宽该闸门。 + * + * 返回值只用于展示:原始 `0x`、`detail.value` / `rawValue`、编辑草稿和数据库 bytes 都不受影响。 + */ +export function binaryCellTextPreview(value: unknown, encoding: BinaryCellTextEncoding, columnType?: string, databaseType?: DatabaseType, incomplete?: boolean): BinaryCellTextPreviewResult { + if (incomplete) return { ok: false, error: "incomplete" }; + if (!isBinaryCellColumnType(columnType)) return { ok: false, error: "notBinary" }; + // 大值闸门会把 `0x` 本身截成 `0x...`,此时可解析的前缀不是完整 bytes,不能拿去解码。 + if (typeof value === "string" && TRUNCATED_HEX_VALUE_RE.test(value.trim())) return { ok: false, error: "notBinary" }; + const bytes = parseBinaryCellBytes(value, columnType, databaseType); + if (!bytes) return { ok: false, error: "notBinary" }; + const preview = binaryPreviewBytes(bytes, columnType); + const text = encoding === "gbk" ? printableGbkText(preview) : printableUtf8Text(preview); + if (text === null) return { ok: false, error: "undecodable" }; + return { ok: true, text, encoding, byteLength: bytes.length }; +} + // 复制到剪贴板时,把「文本型」MySQL VARBINARY 单元格还原成其原始字符串。 // DBX 后端为保留任意 bytes,把该值统一序列化成 `0x`;前端只有在严格 UTF-8 解码、 // 无控制字符且重新编码后与原 bytes 完全一致时,才把 payload(如 token)复制为文本; diff --git a/packages/app-tests/binaryCellDownload.test.ts b/packages/app-tests/binaryCellDownload.test.ts index ea5b322541..c73b30b759 100644 --- a/packages/app-tests/binaryCellDownload.test.ts +++ b/packages/app-tests/binaryCellDownload.test.ts @@ -8,6 +8,7 @@ import { binaryCellClipboardText, binaryCellDownloadFileName, binaryCellDownloadPayload, + binaryCellTextPreview, canDownloadBinaryCellValue, formatBinaryCellByteSize, isBinaryCellColumnType, @@ -108,6 +109,83 @@ test("binaryCellUtf8Text only returns strict printable text", () => { assert.equal(binaryCellUtf8Text("0x4869", "varchar", "mysql"), null); }); +// issue #9505:GaussDB(ZenithDriver)的 BLOB 已经以 binary canonical form `0x` 到达前端, +// 缺的是单元格详情里“显式、只读”的文本查看入口。此 helper 是通用 binary presentation 层, +// 不参与 MySQL BLOB 的自动文本预览闸门,也不进入编辑/提交路径。 +function previewText(value: unknown, encoding: "utf8" | "gbk", columnType?: string, databaseType?: Parameters[3], incomplete?: boolean): string | null { + const result = binaryCellTextPreview(value, encoding, columnType, databaseType, incomplete); + return result.ok ? result.text : null; +} + +test("binaryCellTextPreview decodes explicit binary cells to strict UTF-8 text", () => { + assert.deepEqual(binaryCellTextPreview("0x48656c6c6f", "utf8", "BLOB"), { ok: true, text: "Hello", encoding: "utf8", byteLength: 5 }); + // issue 复现值的前 14 字节:`[[headers = {}`。 + assert.deepEqual(binaryCellTextPreview("0x5b5b68656164657273203d207b7d", "utf8", "BLOB", "gaussdb"), { ok: true, text: "[[headers = {}", encoding: "utf8", byteLength: 14 }); + // 中文 UTF-8。 + assert.equal(previewText("0xe4b8ade69687", "utf8", "BLOB"), "中文"); + // 非 MySQL 连接同样可以显式查看:属于用户主动触发的只读 presentation。 + assert.equal(previewText("0x48656c6c6f", "utf8", "BLOB"), "Hello"); + assert.equal(previewText("0x48656c6c6f", "utf8", "BYTEA", "postgres"), "Hello"); + assert.equal(previewText("0x48656c6c6f", "utf8", "RAW(2000)", "oracle"), "Hello"); + assert.equal(previewText("0x48656c6c6f", "utf8", "LONG RAW", "oracle"), "Hello"); + assert.equal(previewText("0x48656c6c6f", "utf8", "IMAGE", "sqlserver"), "Hello"); + // 入口可见性完全复用 isBinaryCellColumnType(),本次不放宽该闸门。 + for (const type of ["BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", "BINARY(8)", "VARBINARY(255)", "BYTEA", "RAW(2000)", "LONG RAW", "IMAGE", "bytes"]) { + assert.equal(isBinaryCellColumnType(type), true, type); + assert.equal(previewText("0x48656c6c6f", "utf8", type), "Hello", type); + } + // byte[] / Buffer 形态的 binary cell 同样支持。 + assert.equal(previewText([72, 105], "utf8", "VARBINARY(2)"), "Hi"); + assert.equal(previewText({ type: "Buffer", data: [72, 105] }, "utf8", "VARBINARY(2)"), "Hi"); + // 定长 BINARY 的尾部填充 NUL 与网格预览一致地裁掉。 + assert.equal(previewText("0x3135303031300000", "utf8", "BINARY(8)"), "150010"); + // 与“下载为 UTF-8/GBK”共用同一套 bytes primitive。 + assert.deepEqual(Array.from(parseBinaryCellBytes("0x48656c6c6f", "BLOB") ?? []), Array.from(binaryCellDownloadPayload("0x48656c6c6f", "binary", "BLOB").data as Uint8Array)); +}); + +test("binaryCellTextPreview decodes GBK bytes when the user picks GBK", () => { + assert.equal(previewText("0xd6d0cec4", "gbk", "BLOB"), "中文"); + assert.equal(previewText("0xd6d0cec4", "gbk", "BLOB", "gaussdb"), "中文"); + // UTF-8 仍是默认选项:GBK 字节不会被静默替换字符冒充文本。 + assert.deepEqual(binaryCellTextPreview("0xd6d0cec4", "utf8", "BLOB"), { ok: false, error: "undecodable" }); +}); + +// 真实二进制(图片头、非法序列、控制字节、GBK 私用区)必须显式失败,绝不能静默替换字符。 +test("binaryCellTextPreview refuses real binary instead of silently replacing characters", () => { + assert.deepEqual(binaryCellTextPreview("0x89504e470d0a1a0a", "utf8", "BLOB"), { ok: false, error: "undecodable" }); + assert.deepEqual(binaryCellTextPreview("0xffd8ffe0", "utf8", "LONGBLOB"), { ok: false, error: "undecodable" }); + assert.deepEqual(binaryCellTextPreview("0xfffe", "utf8", "BLOB"), { ok: false, error: "undecodable" }); + assert.deepEqual(binaryCellTextPreview("0x0061", "utf8", "BLOB"), { ok: false, error: "undecodable" }); + assert.deepEqual(binaryCellTextPreview("0x89504e470d0a1a0a", "gbk", "BLOB"), { ok: false, error: "undecodable" }); + assert.deepEqual(binaryCellTextPreview("0xffff", "gbk", "BLOB"), { ok: false, error: "undecodable" }); + assert.deepEqual(binaryCellTextPreview("0x0102", "gbk", "BLOB"), { ok: false, error: "undecodable" }); +}); + +test("binaryCellTextPreview stays out of non-binary and incomplete cells", () => { + assert.deepEqual(binaryCellTextPreview("0x48656c6c6f", "utf8", "varchar(20)"), { ok: false, error: "notBinary" }); + assert.deepEqual(binaryCellTextPreview("0x48656c6c6f", "utf8"), { ok: false, error: "notBinary" }); + assert.deepEqual(binaryCellTextPreview("Hello", "utf8", "BLOB"), { ok: false, error: "notBinary" }); + assert.deepEqual(binaryCellTextPreview(null, "utf8", "BLOB"), { ok: false, error: "notBinary" }); + // 大值闸门把 `0x` 本身截成 `0x...` 时,前缀不是完整 bytes。 + assert.deepEqual(binaryCellTextPreview("0xffd8ffe000104a46...", "utf8", "LONGBLOB"), { ok: false, error: "notBinary" }); + // 后端返回的值本身不完整时不解码残缺 bytes。 + assert.deepEqual(binaryCellTextPreview("0x48656c6c6f", "utf8", "BLOB", undefined, true), { ok: false, error: "incomplete" }); + // TDengine 的 BINARY 裸 hex 是文本而不是 bytes。 + assert.deepEqual(binaryCellTextPreview("66", "utf8", "BINARY(16)", "tdengine"), { ok: false, error: "notBinary" }); +}); + +test("binaryCellTextPreview never mutates the canonical binary value", () => { + const canonical = "0x48656c6c6f"; + assert.equal(previewText(canonical, "utf8", "BLOB"), "Hello"); + assert.equal(canonical, "0x48656c6c6f"); + const bytes = [0x48, 0x69]; + assert.equal(previewText(bytes, "utf8", "VARBINARY(2)"), "Hi"); + assert.deepEqual(bytes, [0x48, 0x69]); + const ascii = "0x534e2d4130303031"; + assert.equal(previewText(ascii, "gbk", "VARBINARY(8)"), "SN-A0001"); + assert.equal(ascii, "0x534e2d4130303031"); +}); + // 群反馈:MySQL varbinary 里以 GBK 写入的中文(Navicat 按连接字符集直接显示)。 // UTF-8 严格解码失败后,仅 MySQL 的 binary/varbinary 在显示/复制路径回退严格 GBK; // BLOB 与编辑写回路径保持纯 UTF-8(与 coerceMysqlBlobTextValue 同闸门)。
{{ t(errorKey) }}
{{ t("grid.binaryTextPreviewOriginalHint") }}