diff --git a/src/feishu/tools/__tests__/doc.test.ts b/src/feishu/tools/__tests__/doc.test.ts index f7eba58..2687003 100644 --- a/src/feishu/tools/__tests__/doc.test.ts +++ b/src/feishu/tools/__tests__/doc.test.ts @@ -15,6 +15,7 @@ const mockDocxDocumentBlockList = vi.fn(); const mockDocxDocumentBlockBatchUpdate = vi.fn(); const mockDocxDocumentBlockChildrenBatchDelete = vi.fn(); const mockDocxDocumentBlockChildrenCreate = vi.fn(); +const mockDocxDocumentBlockDescendantCreate = vi.fn(); vi.mock('../../client.js', () => ({ feishuClient: { @@ -32,6 +33,9 @@ vi.mock('../../client.js', () => ({ batchDelete: (...args: unknown[]) => mockDocxDocumentBlockChildrenBatchDelete(...args), create: (...args: unknown[]) => mockDocxDocumentBlockChildrenCreate(...args), }, + documentBlockDescendant: { + create: (...args: unknown[]) => mockDocxDocumentBlockDescendantCreate(...args), + }, }, }, }, @@ -334,7 +338,7 @@ describe('feishu_doc tool', () => { code: 0, data: { items: [ - { block_id: 'page_1', block_type: 1 }, + { block_id: 'page_1', block_type: 1, children: ['text_1'] }, { block_id: 'text_1', block_type: 2 }, ], }, @@ -360,7 +364,7 @@ describe('feishu_doc tool', () => { code: 0, data: { items: [ - { block_id: 'page_1', block_type: 1 }, + { block_id: 'page_1', block_type: 1, children: ['text_1'] }, { block_id: 'text_1', block_type: 2 }, ], }, @@ -385,6 +389,62 @@ describe('feishu_doc tool', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain('99999'); }); + + it('deletes by page direct-children count, not the flat block total (doc with a native table)', async () => { + // documentBlock.list returns a FLAT list: the page has 4 DIRECT children + // (h1, p1, table, p2), but the table's 4 cells + 4 cell-texts are also listed → + // 12 non-page blocks total. batchDelete removes the page's direct children, so + // end_index must be 4, not 12 — else a doc containing a native table fails with 1770001. + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { + items: [ + { block_id: 'page_1', block_type: 1, children: ['h1', 'p1', 'tbl', 'p2'] }, + { block_id: 'h1', block_type: 3 }, + { block_id: 'p1', block_type: 2 }, + { block_id: 'tbl', block_type: 31, children: ['c0', 'c1', 'c2', 'c3'] }, + { block_id: 'c0', block_type: 32, children: ['t0'] }, + { block_id: 'c1', block_type: 32, children: ['t1'] }, + { block_id: 'c2', block_type: 32, children: ['t2'] }, + { block_id: 'c3', block_type: 32, children: ['t3'] }, + { block_id: 't0', block_type: 2 }, + { block_id: 't1', block_type: 2 }, + { block_id: 't2', block_type: 2 }, + { block_id: 't3', block_type: 2 }, + { block_id: 'p2', block_type: 2 }, + ], + }, + }); + mockDocxDocumentBlockChildrenBatchDelete.mockResolvedValue({ code: 0 }); + mockDocxDocumentBlockChildrenCreate.mockResolvedValue({ code: 0 }); + + const result = await capturedHandler({ + action: 'write', doc_token: 'ABC123', content: 'hello', + }); + + expect(result.content[0].text).toBe('文档已更新'); + expect(result.isError).toBeUndefined(); + expect(mockDocxDocumentBlockChildrenBatchDelete).toHaveBeenCalledTimes(1); + const delArg = mockDocxDocumentBlockChildrenBatchDelete.mock.calls[0][0]; + expect(delArg.data.start_index).toBe(0); + expect(delArg.data.end_index).toBe(4); // page direct children — NOT 12 flat blocks + expect(delArg.path.block_id).toBe('page_1'); + }); + + it('skips batchDelete when the page has no direct children (empty doc)', async () => { + mockDocxDocumentBlockList.mockResolvedValue({ + code: 0, + data: { items: [{ block_id: 'page_1', block_type: 1, children: [] }] }, + }); + mockDocxDocumentBlockChildrenCreate.mockResolvedValue({ code: 0 }); + + const result = await capturedHandler({ + action: 'write', doc_token: 'ABC123', content: 'hi', + }); + + expect(result.content[0].text).toBe('文档已更新'); + expect(mockDocxDocumentBlockChildrenBatchDelete).not.toHaveBeenCalled(); + }); }); describe('append action', () => { diff --git a/src/feishu/tools/__tests__/markdown-to-blocks.test.ts b/src/feishu/tools/__tests__/markdown-to-blocks.test.ts index 398bfaf..19b67c9 100644 --- a/src/feishu/tools/__tests__/markdown-to-blocks.test.ts +++ b/src/feishu/tools/__tests__/markdown-to-blocks.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest'; import { parseInlineMarkdown, markdownToBlocks, + markdownToSegments, + parseMarkdownTable, + buildTableDescendants, batchBlocks, BLOCK_TYPE, LANGUAGE_MAP, @@ -351,3 +354,180 @@ describe('batchBlocks', () => { expect(batchBlocks([])).toEqual([]); }); }); + +describe('parseMarkdownTable', () => { + it('should parse a GFM table with header separator', () => { + const table = parseMarkdownTable([ + '| A | B | C |', + '| --- | --- | --- |', + '| 1 | 2 | 3 |', + '| 4 | 5 | 6 |', + ]); + expect(table).not.toBeNull(); + expect(table!.hasHeader).toBe(true); + expect(table!.columnSize).toBe(3); + expect(table!.rowSize).toBe(3); // header + 2 body rows + expect(table!.rows[0]).toEqual(['A', 'B', 'C']); + expect(table!.rows[1]).toEqual(['1', '2', '3']); + expect(table!.rows[2]).toEqual(['4', '5', '6']); + }); + + it('should recognize alignment separators (:--, --:, :-:)', () => { + const table = parseMarkdownTable([ + '| L | C | R |', + '| :-- | :-: | --: |', + '| a | b | c |', + ]); + expect(table).not.toBeNull(); + expect(table!.hasHeader).toBe(true); + expect(table!.rowSize).toBe(2); + }); + + it('should pad ragged rows to the widest column count', () => { + const table = parseMarkdownTable([ + '| A | B | C |', + '| --- | --- | --- |', + '| 1 | 2 |', + ]); + expect(table!.columnSize).toBe(3); + expect(table!.rows[1]).toEqual(['1', '2', '']); + }); + + it('should treat a table without a separator as headerless', () => { + const table = parseMarkdownTable([ + '| 1 | 2 |', + '| 3 | 4 |', + ]); + expect(table).not.toBeNull(); + expect(table!.hasHeader).toBe(false); + expect(table!.rowSize).toBe(2); + expect(table!.rows[0]).toEqual(['1', '2']); + }); + + it('should return null when there are no data rows', () => { + expect(parseMarkdownTable(['| --- | --- |'])).toBeNull(); + }); +}); + +describe('markdownToSegments', () => { + it('should emit a single blocks segment for flat markdown', () => { + const segments = markdownToSegments('# Title\n\nsome text'); + expect(segments).toHaveLength(1); + expect(segments[0].type).toBe('blocks'); + }); + + it('should split flat content and tables into ordered segments', () => { + const md = [ + 'intro paragraph', + '', + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + '', + 'outro paragraph', + ].join('\n'); + const segments = markdownToSegments(md); + expect(segments.map((s) => s.type)).toEqual(['blocks', 'table', 'blocks']); + expect(segments[1].type).toBe('table'); + if (segments[1].type === 'table') { + expect(segments[1].table.columnSize).toBe(2); + expect(segments[1].table.hasHeader).toBe(true); + } + }); + + it('should keep two adjacent tables as separate segments', () => { + const md = [ + '| A |', + '| --- |', + '| 1 |', + '', + '| B |', + '| --- |', + '| 2 |', + ].join('\n'); + const segments = markdownToSegments(md); + expect(segments.map((s) => s.type)).toEqual(['table', 'table']); + }); + + it('should fall back to a code block for malformed pipe lines', () => { + // Only a separator line — not a usable table. + const segments = markdownToSegments('| --- | --- |'); + expect(segments).toHaveLength(1); + expect(segments[0].type).toBe('blocks'); + if (segments[0].type === 'blocks') { + expect(segments[0].blocks[0].block_type).toBe(BLOCK_TYPE.CODE); + } + }); +}); + +describe('buildTableDescendants', () => { + it('should build a native table structure with correct block types', () => { + const table = parseMarkdownTable([ + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + ])!; + const { childrenId, descendants } = buildTableDescendants(table); + + // Top-level attaches exactly the table block. + expect(childrenId).toHaveLength(1); + const tableBlock = descendants.find((b) => b.block_id === childrenId[0])!; + expect(tableBlock.block_type).toBe(BLOCK_TYPE.TABLE); + expect(tableBlock.table).toEqual({ + property: { row_size: 2, column_size: 2, header_row: true }, + }); + + // row_size * column_size cells, each referenced by the table in row-major order. + const cellIds = tableBlock.children as string[]; + expect(cellIds).toHaveLength(4); + for (const id of cellIds) { + const cell = descendants.find((b) => b.block_id === id)!; + expect(cell.block_type).toBe(BLOCK_TYPE.TABLE_CELL); + expect((cell.children as string[]).length).toBe(1); + } + + // Every cell has a text child; descendants total = 1 table + 4 cells + 4 texts. + expect(descendants).toHaveLength(9); + const textBlocks = descendants.filter((b) => b.block_type === BLOCK_TYPE.TEXT); + expect(textBlocks).toHaveLength(4); + }); + + it('should render inline markdown inside cells', () => { + const table = parseMarkdownTable([ + '| **bold** | plain |', + '| --- | --- |', + '| x | y |', + ])!; + const { descendants } = buildTableDescendants(table); + const headerText = descendants.find((b) => b.block_id === 't_0_0')!; + const elements = (headerText.text as { elements: unknown[] }).elements as Array<{ + text_run: { content: string; text_element_style?: { bold?: boolean } }; + }>; + expect(elements[0].text_run.content).toBe('bold'); + expect(elements[0].text_run.text_element_style?.bold).toBe(true); + }); + + it('should give empty cells a single empty-content text_run (Feishu rejects empty elements)', () => { + const table = parseMarkdownTable([ + '| A | B |', + '| --- | --- |', + '| 1 | |', + ])!; + const { descendants } = buildTableDescendants(table); + const emptyCellText = descendants.find((b) => b.block_id === 't_1_1')!; + // Verified against the live Feishu API: `elements: []` returns 1770001 invalid param. + expect((emptyCellText.text as { elements: unknown[] }).elements).toEqual([ + { text_run: { content: '' } }, + ]); + }); +}); + +describe('markdownToBlocks (table backward compat)', () => { + it('should render tables as a plaintext code block in the flat API', () => { + const blocks = markdownToBlocks('| A | B |\n| --- | --- |\n| 1 | 2 |'); + expect(blocks).toHaveLength(1); + expect(blocks[0].block_type).toBe(BLOCK_TYPE.CODE); + const code = blocks[0].code as { language: number }; + expect(code.language).toBe(1); // plaintext + }); +}); diff --git a/src/feishu/tools/doc.ts b/src/feishu/tools/doc.ts index 93e0d9a..daaef33 100644 --- a/src/feishu/tools/doc.ts +++ b/src/feishu/tools/doc.ts @@ -4,7 +4,12 @@ import { feishuClient } from '../client.js'; import { logger } from '../../utils/logger.js'; import { validateToken } from './validation.js'; import { grantOwnerPermission, grantChatMembersPermission } from './permissions.js'; -import { markdownToBlocks, batchBlocks, parseInlineMarkdown } from './markdown-to-blocks.js'; +import { + markdownToSegments, + buildTableDescendants, + batchBlocks, + parseInlineMarkdown, +} from './markdown-to-blocks.js'; /** Max lines returned by read before truncation (matches Claude Code's Read tool default) */ const READ_LINE_LIMIT = 2000; @@ -14,16 +19,16 @@ const BLOCK_TYPE_NAMES: Record = { 1: 'page', 2: 'text', 3: 'heading1', 4: 'heading2', 5: 'heading3', 6: 'heading4', 7: 'heading5', 8: 'heading6', 9: 'heading7', 10: 'heading8', 11: 'heading9', 12: 'bullet', 13: 'ordered', - 14: 'code', 15: 'quote', 16: 'todo', 17: 'bitable', 18: 'callout', - 19: 'chat_card', 20: 'diagram', 21: 'divider', 22: 'file', - 23: 'grid', 24: 'grid_column', 25: 'iframe', 26: 'image', - 27: 'isv', 28: 'mindnote', 29: 'sheet', 30: 'table', - 31: 'table_cell', 32: 'view', 33: 'undefined', 999: 'virtual_merge', - 34: 'quote_container', 40: 'task', 41: 'okr', + 14: 'code', 15: 'quote', 17: 'todo', 18: 'bitable', 19: 'callout', + 20: 'chat_card', 21: 'diagram', 22: 'divider', 23: 'file', + 24: 'grid', 25: 'grid_column', 26: 'iframe', 27: 'image', + 28: 'isv', 29: 'mindnote', 30: 'sheet', 31: 'table', + 32: 'table_cell', 33: 'view', 34: 'quote_container', + 40: 'task', 41: 'okr', 42: 'okr_objective', 43: 'okr_key_result', 44: 'okr_progress', 46: 'add_ons', 48: 'jira_issue', 49: 'wiki_catalog', 51: 'board', 52: 'agenda', 53: 'agenda_item', - 54: 'agenda_item_content', + 54: 'agenda_item_content', 999: 'undefined', }; /** @@ -58,6 +63,58 @@ function extractBlockText(block: Record): string { return ''; } +/** + * 将 Markdown 内容写入文档指定父 block,支持渲染原生飞书表格。 + * + * 普通 block 走 `documentBlockChildren.create`(分批);每个 Markdown 表格通过 + * `documentBlockDescendant.create` 创建为原生 `table`。segment 按顺序写入,当传入 + * `startIndex` 时,插入位置按已写入的顶层 block 数量递增,从而保证混排内容的顺序。 + * + * @returns 写入的顶层 block 数量。 + */ +async function writeMarkdownContent( + client: typeof feishuClient.raw, + documentId: string, + parentBlockId: string, + markdown: string, + startIndex?: number, +): Promise { + const segments = markdownToSegments(markdown); + let index = startIndex; + let written = 0; + + for (const seg of segments) { + if (seg.type === 'blocks') { + for (const batch of batchBlocks(seg.blocks)) { + const resp = await client.docx.documentBlockChildren.create({ + path: { document_id: documentId, block_id: parentBlockId }, + data: { children: batch, ...(index != null ? { index } : {}) }, + }); + if (resp.code !== 0) throw new Error(`写入 blocks 失败 (${resp.code}): ${resp.msg}`); + if (index != null) index += batch.length; + written += batch.length; + } + } else { + const { childrenId, descendants } = buildTableDescendants(seg.table); + const resp = await client.docx.documentBlockDescendant.create({ + path: { document_id: documentId, block_id: parentBlockId }, + data: { + children_id: childrenId, + descendants: descendants as unknown as NonNullable< + Parameters[0] + >['data']['descendants'], + ...(index != null ? { index } : {}), + }, + }); + if (resp.code !== 0) throw new Error(`写入表格失败 (${resp.code}): ${resp.msg}`); + if (index != null) index += childrenId.length; + written += childrenId.length; + } + } + + return written; +} + /** * 飞书文档 MCP 工具 * @@ -80,7 +137,7 @@ export function feishuDocTool(chatId?: string) { '- insert_blocks: 在指定位置插入新 block (需要 block_id 作为父 block,index 指定位置)', '- delete_blocks: 删除指定 block (需要 block_id)', '', - 'write/append/insert_blocks 支持的 Markdown 语法: 标题(#)、加粗(**)、斜体(*)、删除线(~~)、行内代码(`)、链接、无序列表(-)、有序列表(1.)、代码块(```)、待办(- [ ])、分隔线(---)。', + 'write/append/insert_blocks 支持的 Markdown 语法: 标题(#)、加粗(**)、斜体(*)、删除线(~~)、行内代码(`)、链接、无序列表(-)、有序列表(1.)、代码块(```)、待办(- [ ])、分隔线(---)、表格(| 列1 | 列2 |,会转换为原生飞书表格,首行为表头)。', '', '读取文档的推荐流程: read (自动截断大文档) → 如需查看被截断部分,用 list_blocks 定位 → read_blocks 按需读取', '编辑他人文档的推荐流程: list_blocks → 找到目标 block_id → update_block/insert_blocks/delete_blocks', @@ -175,37 +232,24 @@ export function feishuDocTool(chatId?: string) { }); if (listResp.code !== 0) throw new Error(`获取 blocks 失败 (${listResp.code}): ${listResp.msg}`); - // 2. 删除所有子 block (跳过 page block 本身) - const blockIds = (listResp.data?.items ?? []) - .filter((b) => b.block_type !== 1) // block_type 1 = page - .map((b) => b.block_id) - .filter((id): id is string => !!id); - - if (blockIds.length > 0) { - // 获取 page block id - const pageBlock = (listResp.data?.items ?? []).find((b) => b.block_type === 1); - const pageBlockId = pageBlock?.block_id ?? args.doc_token; + // 2. 删除 page 的直接子 block。 + // documentBlock.list 返回的是扁平化的全部 block(含嵌套的 table_cell 及 cell 内 text), + // 而 batchDelete 删的是 page 的**直接子节点** [start_index, end_index),end_index 必须按 + // 直接子块数而非扁平总数——否则含原生表格的文档会因 index 越界被拒 (1770001)。 + const pageBlock = (listResp.data?.items ?? []).find((b) => b.block_type === 1); + const pageBlockId = pageBlock?.block_id ?? args.doc_token; + const directChildCount = (pageBlock?.children as string[] | undefined)?.length ?? 0; + if (directChildCount > 0) { const delResp = await client.docx.documentBlockChildren.batchDelete({ path: { document_id: args.doc_token, block_id: pageBlockId }, - data: { start_index: 0, end_index: blockIds.length }, + data: { start_index: 0, end_index: directChildCount }, }); if (delResp.code !== 0) throw new Error(`删除 blocks 失败 (${delResp.code}): ${delResp.msg}`); } // 3. 将 Markdown 转换为 block 并批量写入 - const pageBlock2 = (listResp.data?.items ?? []).find((b) => b.block_type === 1); - const pageBlockId2 = pageBlock2?.block_id ?? args.doc_token; - - const blocks = markdownToBlocks(args.content); - const batches = batchBlocks(blocks); - for (const batch of batches) { - const createResp1 = await client.docx.documentBlockChildren.create({ - path: { document_id: args.doc_token, block_id: pageBlockId2 }, - data: { children: batch }, - }); - if (createResp1.code !== 0) throw new Error(`写入 blocks 失败 (${createResp1.code}): ${createResp1.msg}`); - } + await writeMarkdownContent(client, args.doc_token, pageBlockId, args.content); return { content: [{ type: 'text' as const, text: '文档已更新' }] }; } @@ -220,15 +264,7 @@ export function feishuDocTool(chatId?: string) { const pageBlock3 = (listResp2.data?.items ?? []).find((b) => b.block_type === 1); const pageBlockId3 = pageBlock3?.block_id ?? args.doc_token; - const appendBlocks = markdownToBlocks(args.content); - const appendBatches = batchBlocks(appendBlocks); - for (const batch of appendBatches) { - const createResp2 = await client.docx.documentBlockChildren.create({ - path: { document_id: args.doc_token, block_id: pageBlockId3 }, - data: { children: batch }, - }); - if (createResp2.code !== 0) throw new Error(`追加 blocks 失败 (${createResp2.code}): ${createResp2.msg}`); - } + await writeMarkdownContent(client, args.doc_token, pageBlockId3, args.content); return { content: [{ type: 'text' as const, text: '内容已追加' }] }; } @@ -248,16 +284,11 @@ export function feishuDocTool(chatId?: string) { // 如果提供了 content,创建后自动写入,避免空文档 if (args.content) { - const blocks = markdownToBlocks(args.content); - const batches = batchBlocks(blocks); - for (const batch of batches) { - const writeResp = await client.docx.documentBlockChildren.create({ - path: { document_id: doc.document_id, block_id: doc.document_id }, - data: { children: batch }, - }); - if (writeResp.code !== 0) { - logger.warn({ code: writeResp.code, msg: writeResp.msg }, 'create: 写入内容失败,文档已创建但为空'); - } + try { + await writeMarkdownContent(client, doc.document_id, doc.document_id, args.content); + } catch (writeErr) { + const wmsg = writeErr instanceof Error ? writeErr.message : String(writeErr); + logger.warn({ err: wmsg }, 'create: 写入内容失败,文档已创建但内容可能不完整'); } } } @@ -344,7 +375,7 @@ export function feishuDocTool(chatId?: string) { const typeId = block.block_type as number; const typeName = BLOCK_TYPE_NAMES[typeId] ?? `type_${typeId}`; const text = extractBlockText(block); - if (text || typeId === 21 /* divider */) { + if (text || typeId === 22 /* divider */) { blockTexts.push(`[${typeName}] ${text}`); } } @@ -376,21 +407,10 @@ export function feishuDocTool(chatId?: string) { if (!args.doc_token) throw new Error('insert_blocks 操作需要 doc_token'); if (!args.block_id) throw new Error('insert_blocks 操作需要 block_id (父 block)'); if (!args.content) throw new Error('insert_blocks 操作需要 content'); - const insertedBlocks = markdownToBlocks(args.content); - const insertBatches = batchBlocks(insertedBlocks); - let currentIndex = args.index; - for (const batch of insertBatches) { - const createResp3 = await client.docx.documentBlockChildren.create({ - path: { document_id: args.doc_token, block_id: args.block_id }, - data: { - children: batch, - ...(currentIndex != null ? { index: currentIndex } : {}), - }, - }); - if (createResp3.code !== 0) throw new Error(`插入 blocks 失败 (${createResp3.code}): ${createResp3.msg}`); - if (currentIndex != null) currentIndex += batch.length; - } - return { content: [{ type: 'text' as const, text: `已插入 ${insertedBlocks.length} 个 block` }] }; + const insertedCount = await writeMarkdownContent( + client, args.doc_token, args.block_id, args.content, args.index, + ); + return { content: [{ type: 'text' as const, text: `已插入 ${insertedCount} 个 block` }] }; } case 'delete_blocks': { diff --git a/src/feishu/tools/markdown-to-blocks.ts b/src/feishu/tools/markdown-to-blocks.ts index a24c55a..4c0835c 100644 --- a/src/feishu/tools/markdown-to-blocks.ts +++ b/src/feishu/tools/markdown-to-blocks.ts @@ -24,6 +24,8 @@ export const BLOCK_TYPE = { QUOTE: 15, TODO: 17, DIVIDER: 22, + TABLE: 31, + TABLE_CELL: 32, } as const; // Map language names to Feishu's language enum values @@ -175,12 +177,96 @@ export function parseInlineMarkdown(text: string): TextElement[] { return elements; } -/** Convert Markdown text to an array of Feishu document blocks. */ -export function markdownToBlocks(markdown: string): FeishuBlock[] { +/** A parsed Markdown table, normalized so every row has `columnSize` cells. */ +export interface TableData { + /** Cell text in row-major order; row 0 is the header when `hasHeader` is true. */ + rows: string[][]; + rowSize: number; + columnSize: number; + hasHeader: boolean; +} + +/** A converted Markdown segment: either a run of flat blocks or a native table. */ +export type MarkdownSegment = + | { type: 'blocks'; blocks: FeishuBlock[] } + | { type: 'table'; table: TableData }; + +/** Split a single Markdown table row into trimmed cell strings. */ +function splitTableRow(line: string): string[] { + let s = line.trim(); + if (s.startsWith('|')) s = s.slice(1); + if (s.endsWith('|')) s = s.slice(0, -1); + return s.split('|').map((c) => c.trim()); +} + +/** A separator row looks like |---|:--:|--:| — dashes with optional leading/trailing colons. */ +function isTableSeparator(cells: string[]): boolean { + return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c.replace(/\s/g, ''))); +} + +/** + * Parse collected Markdown table lines into normalized {@link TableData}. + * Returns null if the lines don't form a usable table. + */ +export function parseMarkdownTable(tableLines: string[]): TableData | null { + const rawRows = tableLines.map(splitTableRow); + let hasHeader = false; + let dataRows: string[][]; + + // GFM tables put the alignment separator on the second line. + if (rawRows.length >= 2 && isTableSeparator(rawRows[1])) { + hasHeader = true; + dataRows = [rawRows[0], ...rawRows.slice(2)]; + } else { + dataRows = rawRows.filter((r) => !isTableSeparator(r)); + } + + // Drop fully-empty rows (e.g. produced by a trailing pipe-only line). + dataRows = dataRows.filter((r) => !(r.length === 1 && r[0] === '')); + if (dataRows.length === 0) return null; + + const columnSize = Math.max(...dataRows.map((r) => r.length)); + if (columnSize === 0) return null; + + const rows = dataRows.map((r) => { + const cells = r.slice(0, columnSize); + while (cells.length < columnSize) cells.push(''); + return cells; + }); + + return { rows, rowSize: rows.length, columnSize, hasHeader }; +} + +/** Render raw table lines as a plaintext code block (fallback for the flat-block API). */ +function tableToCodeBlock(tableLines: string[]): FeishuBlock { + const chunks = splitLongContent(tableLines.join('\n')); + return { + block_type: BLOCK_TYPE.CODE, + code: { + elements: chunks.map((c) => ({ text_run: { content: c } })), + language: 1, // plaintext + }, + }; +} + +/** + * Convert Markdown into ordered segments. Flat blocks (headings, lists, text…) are + * grouped together; each Markdown table becomes its own `table` segment so callers + * can render it as a native Feishu table via `documentBlockDescendant.create`. + */ +export function markdownToSegments(markdown: string): MarkdownSegment[] { const lines = markdown.split('\n'); - const blocks: FeishuBlock[] = []; + const segments: MarkdownSegment[] = []; + let flat: FeishuBlock[] = []; let i = 0; + const flushFlat = () => { + if (flat.length > 0) { + segments.push({ type: 'blocks', blocks: flat }); + flat = []; + } + }; + while (i < lines.length) { const line = lines[i]; @@ -197,7 +283,7 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { if (i < lines.length) i++; // skip closing ``` only if found const content = codeLines.join('\n'); const chunks = splitLongContent(content); - blocks.push({ + flat.push({ block_type: BLOCK_TYPE.CODE, code: { elements: chunks.map((c) => ({ text_run: { content: c } })), @@ -207,7 +293,7 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { continue; } - // --- Table (render as code block since Feishu API doesn't support inline table creation) --- + // --- Table → its own segment (native Feishu table) --- if (line.includes('|') && line.trim().startsWith('|')) { const tableLines: string[] = [line]; i++; @@ -215,15 +301,14 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { tableLines.push(lines[i]); i++; } - const content = tableLines.join('\n'); - const tableChunks = splitLongContent(content); - blocks.push({ - block_type: BLOCK_TYPE.CODE, - code: { - elements: tableChunks.map((c) => ({ text_run: { content: c } })), - language: 1, // plaintext - }, - }); + const table = parseMarkdownTable(tableLines); + if (table) { + flushFlat(); + segments.push({ type: 'table', table }); + } else { + // Not a usable table — preserve old behavior (plaintext code block). + flat.push(tableToCodeBlock(tableLines)); + } continue; } @@ -234,7 +319,7 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { const text = headingMatch[2].trim(); const blockType = BLOCK_TYPE.HEADING1 + level - 1; const key = `heading${level}`; - blocks.push({ + flat.push({ block_type: blockType, [key]: { elements: parseInlineMarkdown(text) }, }); @@ -244,7 +329,7 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { // --- Horizontal rule --- if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { - blocks.push({ block_type: BLOCK_TYPE.DIVIDER, divider: {} }); + flat.push({ block_type: BLOCK_TYPE.DIVIDER, divider: {} }); i++; continue; } @@ -254,7 +339,7 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { if (todoMatch) { const done = todoMatch[2].toLowerCase() === 'x'; const text = todoMatch[3].trim(); - blocks.push({ + flat.push({ block_type: BLOCK_TYPE.TODO, todo: { elements: parseInlineMarkdown(text), @@ -269,7 +354,7 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { const bulletMatch = line.match(/^(\s*)[-*+]\s+(.*)/); if (bulletMatch) { const text = bulletMatch[2].trim(); - blocks.push({ + flat.push({ block_type: BLOCK_TYPE.BULLET, bullet: { elements: parseInlineMarkdown(text) }, }); @@ -281,7 +366,7 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { const orderedMatch = line.match(/^(\s*)\d+[.)]\s+(.*)/); if (orderedMatch) { const text = orderedMatch[2].trim(); - blocks.push({ + flat.push({ block_type: BLOCK_TYPE.ORDERED, ordered: { elements: parseInlineMarkdown(text) }, }); @@ -297,7 +382,7 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { i++; } const quoteText = quoteLines.join('\n').trim(); - blocks.push({ + flat.push({ block_type: BLOCK_TYPE.TEXT, text: { elements: parseInlineMarkdown(quoteText) }, }); @@ -332,16 +417,88 @@ export function markdownToBlocks(markdown: string): FeishuBlock[] { } const paraText = paraLines.join('\n').trim(); if (paraText) { - blocks.push({ + flat.push({ block_type: BLOCK_TYPE.TEXT, text: { elements: parseInlineMarkdown(paraText) }, }); } } + flushFlat(); + return segments; +} + +/** + * Convert Markdown to a flat array of Feishu blocks (for `documentBlockChildren.create`). + * Tables are rendered as plaintext code blocks here because the flat API can't nest; + * use {@link markdownToSegments} + the descendant API for native tables. + */ +export function markdownToBlocks(markdown: string): FeishuBlock[] { + const blocks: FeishuBlock[] = []; + for (const seg of markdownToSegments(markdown)) { + if (seg.type === 'blocks') { + blocks.push(...seg.blocks); + } else { + const lines = seg.table.rows.map((r) => `| ${r.join(' | ')} |`); + blocks.push(tableToCodeBlock(lines)); + } + } return blocks; } +/** + * Build a `documentBlockDescendant.create` payload for a single native Feishu table. + * Produces the nested `table` → `table_cell` → `text` structure with client-side temp + * block IDs (unique within one request); Feishu assigns real IDs on creation. + */ +export function buildTableDescendants(table: TableData): { + childrenId: string[]; + descendants: FeishuBlock[]; +} { + const tableId = 'tbl'; + const cellIds: string[] = []; + const cellBlocks: FeishuBlock[] = []; + const textBlocks: FeishuBlock[] = []; + + for (let r = 0; r < table.rows.length; r++) { + for (let c = 0; c < table.columnSize; c++) { + const cellId = `c_${r}_${c}`; + const textId = `t_${r}_${c}`; + cellIds.push(cellId); + const content = table.rows[r][c] ?? ''; + cellBlocks.push({ + block_id: cellId, + block_type: BLOCK_TYPE.TABLE_CELL, + table_cell: {}, + children: [textId], + }); + textBlocks.push({ + block_id: textId, + block_type: BLOCK_TYPE.TEXT, + // Feishu rejects a text block with an empty `elements` array (1770001), so empty + // cells still get one empty-content text_run — which is what parseInlineMarkdown('') + // returns, so no special-casing is needed here. + text: { elements: parseInlineMarkdown(content) }, + }); + } + } + + const tableBlock: FeishuBlock = { + block_id: tableId, + block_type: BLOCK_TYPE.TABLE, + table: { + property: { + row_size: table.rowSize, + column_size: table.columnSize, + header_row: table.hasHeader, + }, + }, + children: cellIds, + }; + + return { childrenId: [tableId], descendants: [tableBlock, ...cellBlocks, ...textBlocks] }; +} + /** * Split blocks into batches for safe insertion. * Feishu API limits the number of blocks per request (~50).