diff --git a/blocksuite/blocks/src/__tests__/adapters/plain-text.unit.spec.ts b/blocksuite/blocks/src/__tests__/adapters/plain-text.unit.spec.ts index 705f40d3c7c7a..c7590edc49a27 100644 --- a/blocksuite/blocks/src/__tests__/adapters/plain-text.unit.spec.ts +++ b/blocksuite/blocks/src/__tests__/adapters/plain-text.unit.spec.ts @@ -1186,4 +1186,229 @@ describe('snapshot to plain text', () => { }); expect(target.file).toBe(plainText); }); + + test('table', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:8Wb7CSJ9Qe', + flavour: 'affine:database', + props: { + cells: { + 'block:P_-Wg7Rg9O': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'TKip9uc7Yx', + }, + 'block:5cglrBmAr3': { + columnId: 'block:5cglrBmAr3', + value: 1702598400000, + }, + 'block:8Fa0JQe7WY': { + columnId: 'block:8Fa0JQe7WY', + value: 1, + }, + 'block:5ej6StPuF_': { + columnId: 'block:5ej6StPuF_', + value: 65, + }, + 'block:DPhZ6JBziD': { + columnId: 'block:DPhZ6JBziD', + value: ['-2_QD3GZT1', '73UrEZWaKk'], + }, + 'block:O8dpIDiP7-': { + columnId: 'block:O8dpIDiP7-', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'test2', + attributes: { + link: 'https://google.com', + }, + }, + ], + }, + }, + 'block:U8lPD59MkF': { + columnId: 'block:U8lPD59MkF', + value: 'https://google.com', + }, + 'block:-DT7B0TafG': { + columnId: 'block:-DT7B0TafG', + value: true, + }, + }, + 'block:0vhfgcHtPF': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'F2bgsaE3X2', + }, + 'block:O8dpIDiP7-': { + columnId: 'block:O8dpIDiP7-', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'test1', + }, + ], + }, + }, + 'block:5cglrBmAr3': { + columnId: 'block:5cglrBmAr3', + value: 1703030400000, + }, + }, + 'block:b4_02QXMAM': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'y3O1A2IHHu', + }, + }, + 'block:W_eirvg7EJ': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + }, + }, + }, + columns: [ + { + type: 'title', + name: 'Title', + data: {}, + id: 'block:2VfUaitjf9', + }, + { + type: 'select', + name: 'Status', + data: { + options: [ + { + id: 'TKip9uc7Yx', + color: 'var(--affine-tag-white)', + value: 'TODO', + }, + { + id: 'F2bgsaE3X2', + color: 'var(--affine-tag-green)', + value: 'In Progress', + }, + { + id: 'y3O1A2IHHu', + color: 'var(--affine-tag-gray)', + value: 'Done', + }, + ], + }, + id: 'block:qyo8q9VPWU', + }, + { + type: 'date', + name: 'Date', + data: {}, + id: 'block:5cglrBmAr3', + }, + { + type: 'number', + name: 'Number', + data: { + decimal: 0, + }, + id: 'block:8Fa0JQe7WY', + }, + { + type: 'progress', + name: 'Progress', + data: {}, + id: 'block:5ej6StPuF_', + }, + { + type: 'multi-select', + name: 'MultiSelect', + data: { + options: [ + { + id: '73UrEZWaKk', + value: 'test2', + color: 'var(--affine-tag-purple)', + }, + { + id: '-2_QD3GZT1', + value: 'test1', + color: 'var(--affine-tag-teal)', + }, + ], + }, + id: 'block:DPhZ6JBziD', + }, + { + type: 'rich-text', + name: 'RichText', + data: {}, + id: 'block:O8dpIDiP7-', + }, + { + type: 'link', + name: 'Link', + data: {}, + id: 'block:U8lPD59MkF', + }, + { + type: 'checkbox', + name: 'Checkbox', + data: {}, + id: 'block:-DT7B0TafG', + }, + ], + }, + children: [ + { + type: 'block', + id: 'block:P_-Wg7Rg9O', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Task 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:0vhfgcHtPF', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Task 2', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const plainText = `\ +| Title | Status | Date | Number | Progress | MultiSelect | RichText | Link | Checkbox | +| ------ | ----------- | ---------- | ------ | -------- | ----------- | ------------------------- | ------------------ | -------- | +| Task 1 | TODO | 2023-12-15 | 1 | 65 | test1,test2 | test2: https://google.com | https://google.com | true | +| Task 2 | In Progress | 2023-12-20 | | | | test1 | | | +`; + const plainTextAdapter = new PlainTextAdapter(createJob()); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); }); diff --git a/blocksuite/blocks/src/_common/adapters/plain-text/block-matcher.ts b/blocksuite/blocks/src/_common/adapters/plain-text/block-matcher.ts index 8d469ea1e60ac..c6a6fb690b3b7 100644 --- a/blocksuite/blocks/src/_common/adapters/plain-text/block-matcher.ts +++ b/blocksuite/blocks/src/_common/adapters/plain-text/block-matcher.ts @@ -12,6 +12,7 @@ import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/ada import { bookmarkBlockPlainTextAdapterMatcher } from '../../../bookmark-block/adapters/plain-text.js'; import { codeBlockPlainTextAdapterMatcher } from '../../../code-block/adapters/plain-text.js'; +import { databaseBlockPlainTextAdapterMatcher } from '../../../database-block/adapters/plain-text.js'; import { dividerBlockPlainTextAdapterMatcher } from '../../../divider-block/adapters/plain-text.js'; import { latexBlockPlainTextAdapterMatcher } from '../../../latex-block/adapters/plain-text.js'; @@ -29,4 +30,5 @@ export const defaultBlockPlainTextAdapterMatchers: BlockPlainTextAdapterMatcher[ embedLinkedDocBlockPlainTextAdapterMatcher, embedSyncedDocBlockPlainTextAdapterMatcher, latexBlockPlainTextAdapterMatcher, + databaseBlockPlainTextAdapterMatcher, ]; diff --git a/blocksuite/blocks/src/database-block/adapters/extension.ts b/blocksuite/blocks/src/database-block/adapters/extension.ts index ce7de5923b772..3fc5acda39ea2 100644 --- a/blocksuite/blocks/src/database-block/adapters/extension.ts +++ b/blocksuite/blocks/src/database-block/adapters/extension.ts @@ -3,9 +3,11 @@ import type { ExtensionType } from '@blocksuite/block-std'; import { DatabaseBlockHtmlAdapterExtension } from './html.js'; import { DatabaseBlockMarkdownAdapterExtension } from './markdown.js'; import { DatabaseBlockNotionHtmlAdapterExtension } from './notion-html.js'; +import { DatabaseBlockPlainTextAdapterExtension } from './plain-text.js'; export const DatabaseBlockAdapterExtensions: ExtensionType[] = [ DatabaseBlockHtmlAdapterExtension, DatabaseBlockMarkdownAdapterExtension, DatabaseBlockNotionHtmlAdapterExtension, + DatabaseBlockPlainTextAdapterExtension, ]; diff --git a/blocksuite/blocks/src/database-block/adapters/plain-text.ts b/blocksuite/blocks/src/database-block/adapters/plain-text.ts new file mode 100644 index 0000000000000..2399aacd76164 --- /dev/null +++ b/blocksuite/blocks/src/database-block/adapters/plain-text.ts @@ -0,0 +1,92 @@ +import { + type Column, + DatabaseBlockSchema, + type SerializedCells, +} from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import type { BlockSnapshot } from '@blocksuite/store'; +import { format } from 'date-fns/format'; + +import { formatTable } from './utils.js'; + +export const databaseBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = + { + flavour: DatabaseBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext, deltaConverter } = context; + const rows: string[][] = []; + const columns = o.node.props.columns as Array; + const children = o.node.children; + const cells = o.node.props.cells as SerializedCells; + const tableCells = children.map((v: BlockSnapshot) => + columns.map(col => { + const cell = cells[v.id]?.[col.id]; + if (!cell && col.type !== 'title') { + return ''; + } + switch (col.type) { + case 'rich-text': + return deltaConverter + .deltaToAST((cell.value as { delta: DeltaInsert[] }).delta) + .join(''); + case 'title': + return deltaConverter + .deltaToAST((v.props.text as { delta: DeltaInsert[] }).delta) + .join(''); + case 'date': + return format(new Date(cell.value as number), 'yyyy-MM-dd'); + case 'select': { + const value = ( + col.data as { options: Array> } + ).options.find(opt => opt.id === cell.value)?.value; + return value || ''; + } + case 'multi-select': { + const value = (cell.value as string[]) + .map( + val => + ( + col.data as { options: Array> } + ).options.find(opt => val === opt.id)?.value + ) + .filter(Boolean) + .join(','); + return value || ''; + } + default: + return String(cell.value); + } + }) + ); + + // Handle first row. + if (Array.isArray(columns)) { + rows.push(columns.map(col => col.name)); + } + + // Handle 2-... rows + tableCells.forEach(children => { + rows.push(children); + }); + + // Convert rows to table string + const tableString = formatTable(rows); + + context.textBuffer.content += tableString; + context.textBuffer.content += '\n'; + + walkerContext.skipAllChildren(); + }, + }, + }; + +export const DatabaseBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(databaseBlockPlainTextAdapterMatcher); diff --git a/blocksuite/blocks/src/database-block/adapters/utils.ts b/blocksuite/blocks/src/database-block/adapters/utils.ts new file mode 100644 index 0000000000000..11fd24bb51e10 --- /dev/null +++ b/blocksuite/blocks/src/database-block/adapters/utils.ts @@ -0,0 +1,32 @@ +function calculateColumnWidths(rows: string[][]): number[] { + return rows[0].map((_, colIndex) => + Math.max(...rows.map(row => (row[colIndex] || '').length)) + ); +} + +function formatRow( + row: string[], + columnWidths: number[], + isHeader: boolean +): string { + const cells = row.map((cell, colIndex) => + cell.padEnd(columnWidths[colIndex], ' ') + ); + const rowString = `| ${cells.join(' | ')} |`; + return isHeader + ? `${rowString}\n${formatSeparator(columnWidths)}` + : rowString; +} + +function formatSeparator(columnWidths: number[]): string { + const separator = columnWidths.map(width => '-'.repeat(width)).join(' | '); + return `| ${separator} |`; +} + +export function formatTable(rows: string[][]): string { + const columnWidths = calculateColumnWidths(rows); + const formattedRows = rows.map((row, index) => + formatRow(row, columnWidths, index === 0) + ); + return formattedRows.join('\n'); +}