From 338db064d3d543782660a0e914aaddb777b1d639 Mon Sep 17 00:00:00 2001 From: yzw Date: Tue, 21 Jul 2026 17:24:07 +0800 Subject: [PATCH 1/2] feat: copy query results as markdown --- chat2db-community-client/package.json | 1 + chat2db-community-client/readme.md | 1 + .../scripts/i18n-source-hashes.json | 4 +- .../ResultSetTable/constants/index.ts | 2 + .../handleCopyAsMarkdown.test.ts | 46 ++++++++++++++++++ .../onContextmenuCell/handleCopyAsMarkdown.ts | 19 ++++++++ .../event/onContextmenuCell/index.ts | 8 ++++ .../event/onContextmenuCell/markdownTable.ts | 48 +++++++++++++++++++ .../src/i18n/en-US/common.ts | 1 + .../src/i18n/es-ES/common.ts | 1 + .../src/i18n/ja-JP/common.ts | 1 + .../src/i18n/ko-KR/common.ts | 1 + .../src/i18n/zh-CN/common.ts | 1 + 13 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts create mode 100644 chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.ts create mode 100644 chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/markdownTable.ts diff --git a/chat2db-community-client/package.json b/chat2db-community-client/package.json index 9d61e0355..0f8972dd4 100644 --- a/chat2db-community-client/package.json +++ b/chat2db-community-client/package.json @@ -30,6 +30,7 @@ "test:base-table-interaction": "tsx src/components/BaseTable/treeInteraction.test.ts", "test:database-object-sorting": "tsx src/blocks/NewTree/utils/sortTreeNodes.test.ts", "test:redis-explorer": "tsx src/blocks/RedisAllData/redisExplorer.test.ts", + "test:result-markdown": "tsx src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts", "test:search-result-query-composer": "tsx src/blocks/SearchResult/components/ScreeningResult/queryComposer.test.ts", "test:search-result-tab-selection": "tsx src/blocks/SearchResult/tabSelection.test.ts", "test:sql-execution-log": "tsx src/service/sqlExecutionLog.test.ts", diff --git a/chat2db-community-client/readme.md b/chat2db-community-client/readme.md index 196e7100c..6327ddb5d 100644 --- a/chat2db-community-client/readme.md +++ b/chat2db-community-client/readme.md @@ -64,6 +64,7 @@ and installers are written under `jpackage/` and are not frontend source files. ```bash yarn run lint yarn run test:i18n +yarn run test:result-markdown yarn run test:sql-in-clipboard ``` diff --git a/chat2db-community-client/scripts/i18n-source-hashes.json b/chat2db-community-client/scripts/i18n-source-hashes.json index be1ef1fb5..acc32e28f 100644 --- a/chat2db-community-client/scripts/i18n-source-hashes.json +++ b/chat2db-community-client/scripts/i18n-source-hashes.json @@ -5,7 +5,7 @@ "es-ES": { "ai.ts": "391a6e14c58d8401bf3bc245eca5bdd4b66efd8225a877bfbbcae01a1c1c019a", "chat.ts": "0cbe009eacbaa95a9a29bbc69588cb547e90b563667c53edec3e54ee64f5667b", - "common.ts": "a7609e98f3debb2d0215d0ad6ce8c569829228f57ee887f010ced6fe7ca9deac", + "common.ts": "89cf7a998224421f3bd529eef5e7f76d0d48d0cfeb8e1b18670cc66a6e4bb6cc", "connection.ts": "41d07fd10c9550ae51995620cc998de5e99add35edb31c2bb592864c76c13688", "dashboard.ts": "b217bbe260c7674d4efad1dd1b61136653858884f5b9b2134cfdf7d1de5a44ee", "editTable.ts": "0f7a808f50053cd52b183d156bb4fd46bb125b8058f8774deb61c7f02894a350", @@ -32,7 +32,7 @@ "ko-KR": { "ai.ts": "391a6e14c58d8401bf3bc245eca5bdd4b66efd8225a877bfbbcae01a1c1c019a", "chat.ts": "0cbe009eacbaa95a9a29bbc69588cb547e90b563667c53edec3e54ee64f5667b", - "common.ts": "a7609e98f3debb2d0215d0ad6ce8c569829228f57ee887f010ced6fe7ca9deac", + "common.ts": "89cf7a998224421f3bd529eef5e7f76d0d48d0cfeb8e1b18670cc66a6e4bb6cc", "connection.ts": "41d07fd10c9550ae51995620cc998de5e99add35edb31c2bb592864c76c13688", "dashboard.ts": "b217bbe260c7674d4efad1dd1b61136653858884f5b9b2134cfdf7d1de5a44ee", "editTable.ts": "0f7a808f50053cd52b183d156bb4fd46bb125b8058f8774deb61c7f02894a350", diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/constants/index.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/constants/index.ts index ce53c8ecc..00b3584c1 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/constants/index.ts +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/constants/index.ts @@ -38,4 +38,6 @@ export enum ContextmenuType { tabSplitField = 'tabSplitField', // Tab-separated value field tabSplitFieldAndValue = 'tabSplitFieldAndValue', + // Markdown table + markdownTable = 'markdownTable', } diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts new file mode 100644 index 000000000..103ee5550 --- /dev/null +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts @@ -0,0 +1,46 @@ +import { formatSelectionAsMarkdown, type IMarkdownSelectionCell } from './markdownTable'; + +function assertEqual(actual: any, expected: any, message: string) { + if (actual !== expected) { + throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +const selection: IMarkdownSelectionCell[][] = [ + [ + { row: 2, col: 2, field: 'name', value: 'Grace | Hopper' }, + { row: 2, col: 1, field: 'id', dataValue: 2 }, + ], + [ + { row: 1, col: 2, field: 'name', value: 'Ada\nLovelace' }, + { row: 1, col: 1, field: 'id', value: 1 }, + ], +]; + +assertEqual( + formatSelectionAsMarkdown(selection, (cell) => ({ id: 'User ID', name: 'Name | bio' })[String(cell.field)]), + [ + '| User ID | Name \\| bio |', + '| --- | --- |', + '| 1 | Ada
Lovelace |', + '| 2 | Grace \\| Hopper |', + ].join('\n'), + 'formats selected cells by row and column with Markdown escaping', +); + +assertEqual( + formatSelectionAsMarkdown([ + [{ row: 3, col: 1, field: 'id', value: null }], + [{ row: 4, col: 2, field: 'note', value: '' }], + ]), + ['| id | note |', '| --- | --- |', '| NULL | |', '| | |'].join('\n'), + 'preserves sparse selections and distinguishes NULL from an empty string', +); + +assertEqual( + formatSelectionAsMarkdown([[{ row: 0, col: 1, field: 'id', value: 'id' }]]), + null, + 'ignores header-only selections', +); + +console.log('copy selection as Markdown tests passed'); diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.ts new file mode 100644 index 000000000..621dd79ee --- /dev/null +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.ts @@ -0,0 +1,19 @@ +import { copyToClipboard } from '@/utils'; +import { formatSelectionAsMarkdown } from './markdownTable'; +import type { ITableInstance } from '@/blocks/CanvasTable/typings'; + +const handleCopyAsMarkdown = (tableInstance: ITableInstance) => { + const columns = tableInstance.columns as any[]; + const markdown = formatSelectionAsMarkdown(tableInstance.getSelectedCellInfos() || [], (cell) => { + const column = columns.find((item) => String(item.field) === String(cell.field)); + return column?.title ?? cell.field ?? ''; + }); + + if (!markdown) { + return false; + } + + return copyToClipboard(markdown); +}; + +export default handleCopyAsMarkdown; diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/index.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/index.ts index 899e2ad17..6d3228781 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/index.ts +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/index.ts @@ -7,6 +7,7 @@ import handleViewUpdateData from './handleViewUpdateData'; import handleSetNull from './handleSetNull'; import handleCopyRow from './handleCopyRow'; import handleCopyAsSqlInValues from './handleCopyAsSqlInValues'; +import handleCopyAsMarkdown from './handleCopyAsMarkdown'; import i18n from '@/i18n'; import { copyToClipboard } from '@/utils'; import { downloadLargeCellValue } from '@/utils/file'; @@ -193,6 +194,13 @@ const onContextmenuCell = (props: IOnContextmenuEvent) => { handleCopyRow({ tableInstance, selectEvent, type: ContextmenuType.tabSplitFieldAndValue }); }, }, + { + key: ContextmenuType.markdownTable, + label: i18n('common.button.markdownTable'), + onClick: () => { + handleCopyAsMarkdown(tableInstance); + }, + }, ], }, }; diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/markdownTable.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/markdownTable.ts new file mode 100644 index 000000000..345f43b85 --- /dev/null +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/markdownTable.ts @@ -0,0 +1,48 @@ +export interface IMarkdownSelectionCell { + col: number; + row: number; + field?: unknown; + value?: unknown; + dataValue?: unknown; +} + +const escapeMarkdownCell = (value: unknown) => { + if (value === null || value === undefined) { + return 'NULL'; + } + + return String(value) + .replace(/\r\n|\r|\n/g, '
') + .replace(/\|/g, '\\|'); +}; + +export const formatSelectionAsMarkdown = ( + selectedCells: IMarkdownSelectionCell[][], + getColumnTitle: (cell: IMarkdownSelectionCell) => unknown = (cell) => cell.field, +) => { + const cells = selectedCells.flat().filter((cell) => cell.row > 0 && cell.col > 0); + if (!cells.length) { + return null; + } + + const columns = [...new Set(cells.map((cell) => cell.col))].sort((left, right) => left - right); + const rows = [...new Set(cells.map((cell) => cell.row))].sort((left, right) => left - right); + const cellByPosition = new Map(cells.map((cell) => [`${cell.row}:${cell.col}`, cell])); + + const header = columns.map((col) => { + const cell = cells.find((item) => item.col === col); + return escapeMarkdownCell(cell ? getColumnTitle(cell) : ''); + }); + const separator = columns.map(() => '---'); + const body = rows.map((row) => + columns.map((col) => { + const cell = cellByPosition.get(`${row}:${col}`); + if (!cell) { + return ''; + } + return escapeMarkdownCell(cell.dataValue !== undefined ? cell.dataValue : cell.value); + }), + ); + + return [header, separator, ...body].map((line) => `| ${line.join(' | ')} |`).join('\n'); +}; diff --git a/chat2db-community-client/src/i18n/en-US/common.ts b/chat2db-community-client/src/i18n/en-US/common.ts index ef152cca7..be27c7895 100644 --- a/chat2db-community-client/src/i18n/en-US/common.ts +++ b/chat2db-community-client/src/i18n/en-US/common.ts @@ -150,6 +150,7 @@ export default { 'common.button.tabularSeparatedValues': 'TAB delimited (data)', 'common.button.tabularSeparatedValuesFieldName': 'TAB delimited (field name)', 'common.button.tabularSeparatedValuesFieldNameAndData': 'Tab-separated (field names and data)', + 'common.button.markdownTable': 'Markdown table', 'common.button.cloneRow': 'Clone row', 'common.button.deleteRow': 'Delete row', 'common.button.setNull': 'Set NULL', diff --git a/chat2db-community-client/src/i18n/es-ES/common.ts b/chat2db-community-client/src/i18n/es-ES/common.ts index f88bac9f5..83ecc4024 100644 --- a/chat2db-community-client/src/i18n/es-ES/common.ts +++ b/chat2db-community-client/src/i18n/es-ES/common.ts @@ -150,6 +150,7 @@ export default { 'common.button.tabularSeparatedValues': 'Delimitado por tabuladores (datos)', 'common.button.tabularSeparatedValuesFieldName': 'Delimitado por tabuladores (nombre de campo)', 'common.button.tabularSeparatedValuesFieldNameAndData': 'Separado por tabuladores (nombres de campo y datos)', + 'common.button.markdownTable': 'Tabla Markdown', 'common.button.cloneRow': 'Clonar fila', 'common.button.deleteRow': 'Eliminar fila', 'common.button.setNull': 'Establecer NULL', diff --git a/chat2db-community-client/src/i18n/ja-JP/common.ts b/chat2db-community-client/src/i18n/ja-JP/common.ts index b7a3ef885..90ff19569 100644 --- a/chat2db-community-client/src/i18n/ja-JP/common.ts +++ b/chat2db-community-client/src/i18n/ja-JP/common.ts @@ -150,6 +150,7 @@ export default { 'common.button.tabularSeparatedValues': 'タブ区切り値', 'common.button.tabularSeparatedValuesFieldName': 'タブ区切り値(フィールド名)', 'common.button.tabularSeparatedValuesFieldNameAndData': 'タブ区切り値(フィールド名とデータ)', + 'common.button.markdownTable': 'Markdown テーブル', 'common.button.cloneRow': '行を複製', 'common.button.deleteRow': '行を削除', 'common.button.setNull': 'NULLに設定', diff --git a/chat2db-community-client/src/i18n/ko-KR/common.ts b/chat2db-community-client/src/i18n/ko-KR/common.ts index 7c244926d..64bfd2895 100644 --- a/chat2db-community-client/src/i18n/ko-KR/common.ts +++ b/chat2db-community-client/src/i18n/ko-KR/common.ts @@ -149,6 +149,7 @@ export default { 'common.button.tabularSeparatedValues': '탭으로 구분(데이터)', 'common.button.tabularSeparatedValuesFieldName': '탭으로 구분(필드 이름)', 'common.button.tabularSeparatedValuesFieldNameAndData': '탭으로 구분(필드 이름 및 데이터)', + 'common.button.markdownTable': 'Markdown 표', 'common.button.cloneRow': '행 복제', 'common.button.deleteRow': '행 삭제', 'common.button.setNull': 'NULL로 설정', diff --git a/chat2db-community-client/src/i18n/zh-CN/common.ts b/chat2db-community-client/src/i18n/zh-CN/common.ts index fc276e43c..605886bb3 100644 --- a/chat2db-community-client/src/i18n/zh-CN/common.ts +++ b/chat2db-community-client/src/i18n/zh-CN/common.ts @@ -148,6 +148,7 @@ export default { 'common.button.tabularSeparatedValues': '制表符分隔值', 'common.button.tabularSeparatedValuesFieldName': '制表符分隔值(字段名)', 'common.button.tabularSeparatedValuesFieldNameAndData': '制表符分隔值(字段名和数据)', + 'common.button.markdownTable': 'Markdown 表格', 'common.button.cloneRow': '克隆行', 'common.button.deleteRow': '删除行', 'common.button.setNull': '设置为NULL', From 5f08299af1c622f4252073cd4b630122873790e0 Mon Sep 17 00:00:00 2001 From: yzw Date: Wed, 22 Jul 2026 11:13:48 +0800 Subject: [PATCH 2/2] fix: address markdown copy review feedback --- .github/workflows/ci.yml | 3 +++ .../event/onContextmenuCell/handleCopyAsMarkdown.test.ts | 6 ++++++ .../ResultSetTable/event/onContextmenuCell/markdownTable.ts | 1 + 3 files changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9ce3fe74..e70a03db0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: - name: Run SQL clipboard test run: yarn run test:sql-in-clipboard + - name: Run result Markdown formatter test + run: yarn run test:result-markdown + - name: Run BaseTable tree interaction contract run: yarn run test:base-table-interaction diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts index 103ee5550..ddc8bc7ea 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/handleCopyAsMarkdown.test.ts @@ -37,6 +37,12 @@ assertEqual( 'preserves sparse selections and distinguishes NULL from an empty string', ); +assertEqual( + formatSelectionAsMarkdown([[{ row: 1, col: 1, field: 'value', value: 'left\\|right' }]]), + ['| value |', '| --- |', '| left\\\\\\|right |'].join('\n'), + 'preserves a backslash followed by a pipe', +); + assertEqual( formatSelectionAsMarkdown([[{ row: 0, col: 1, field: 'id', value: 'id' }]]), null, diff --git a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/markdownTable.ts b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/markdownTable.ts index 345f43b85..c6fecb4d4 100644 --- a/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/markdownTable.ts +++ b/chat2db-community-client/src/blocks/SearchResult/components/ResultSetTable/event/onContextmenuCell/markdownTable.ts @@ -13,6 +13,7 @@ const escapeMarkdownCell = (value: unknown) => { return String(value) .replace(/\r\n|\r|\n/g, '
') + .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|'); };