@max1874/feishu 0.2.16 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max1874/feishu",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Feishu/Lark channel plugin",
6
6
  "license": "MIT",
@@ -47,6 +47,7 @@
47
47
  "dependencies": {
48
48
  "@larksuiteoapi/node-sdk": "^1.30.0",
49
49
  "@sinclair/typebox": "^0.34.48",
50
+ "marked": "^15.0.12",
50
51
  "zod": "^4.3.6"
51
52
  },
52
53
  "devDependencies": {
package/src/docx.ts CHANGED
@@ -4,6 +4,13 @@ import { createFeishuClient } from "./client.js";
4
4
  import type { FeishuConfig } from "./types.js";
5
5
  import type * as Lark from "@larksuiteoapi/node-sdk";
6
6
  import { Readable } from "stream";
7
+ import {
8
+ markdownToFeishuBlocks,
9
+ prepareBlocksForApi,
10
+ TablePlaceholder,
11
+ type FeishuBlock,
12
+ type ContentItem,
13
+ } from "./feishu-renderer.js";
7
14
 
8
15
  // ============ Helpers ============
9
16
 
@@ -43,59 +50,6 @@ function extractImageUrls(markdown: string): string[] {
43
50
  return urls;
44
51
  }
45
52
 
46
- /** Extract text content from a block for sorting */
47
- function extractBlockText(block: any): string {
48
- const elements =
49
- block.text?.elements ??
50
- block.heading1?.elements ??
51
- block.heading2?.elements ??
52
- block.heading3?.elements ??
53
- block.bullet?.elements ??
54
- block.ordered?.elements ??
55
- block.quote?.elements ??
56
- block.todo?.elements ??
57
- [];
58
- return elements
59
- .filter((e: any) => e.text_run)
60
- .map((e: any) => e.text_run.content)
61
- .join("");
62
- }
63
-
64
- /**
65
- * Sort blocks by their position in firstLevelBlockIds.
66
- * The Convert API returns blocks in arbitrary order, but provides
67
- * first_level_block_ids in the correct document order.
68
- */
69
- function sortBlocksByFirstLevelIds(blocks: any[], firstLevelBlockIds: string[]): any[] {
70
- // Build a map of block_id -> block
71
- const blockMap = new Map<string, any>();
72
- for (const block of blocks) {
73
- if (block.block_id) {
74
- blockMap.set(block.block_id, block);
75
- }
76
- }
77
-
78
- // Reorder blocks according to firstLevelBlockIds
79
- const sortedBlocks: any[] = [];
80
- const usedIds = new Set<string>();
81
-
82
- for (const id of firstLevelBlockIds) {
83
- const block = blockMap.get(id);
84
- if (block) {
85
- sortedBlocks.push(block);
86
- usedIds.add(id);
87
- }
88
- }
89
-
90
- // Append any remaining blocks (e.g., child blocks like TableCell)
91
- for (const block of blocks) {
92
- if (block.block_id && !usedIds.has(block.block_id)) {
93
- sortedBlocks.push(block);
94
- }
95
- }
96
-
97
- return sortedBlocks;
98
- }
99
53
 
100
54
  const BLOCK_TYPE_NAMES: Record<number, string> = {
101
55
  1: "Page",
@@ -118,459 +72,150 @@ const BLOCK_TYPE_NAMES: Record<number, string> = {
118
72
  32: "TableCell",
119
73
  };
120
74
 
121
- // Block types that need special handling (not via standard documentBlockChildren.create)
75
+ // Block types
122
76
  const TABLE_BLOCK_TYPE = 31;
123
- const TABLE_CELL_BLOCK_TYPE = 32;
124
-
125
- /** Extracted table data from convert API result */
126
- interface TableData {
127
- rowSize: number;
128
- colSize: number;
129
- cells: string[][]; // 2D array of cell text content
130
- }
131
-
132
- /** A content item that can be either a regular block or a table */
133
- type ContentItem =
134
- | { type: "block"; block: any }
135
- | { type: "table"; table: TableData };
136
-
137
- /** Extract table data from a table block */
138
- function extractTableData(tableBlock: any, blockMap: Map<string, any>): { tableData: TableData; relatedIds: Set<string> } {
139
- const relatedIds = new Set<string>();
140
- relatedIds.add(tableBlock.block_id);
141
-
142
- const tableInfo = tableBlock.table;
143
- const rowSize = tableInfo?.property?.row_size ?? 0;
144
- const colSize = tableInfo?.property?.column_size ?? 0;
145
-
146
- const cells: string[][] = Array.from({ length: rowSize }, () =>
147
- Array.from({ length: colSize }, () => ""),
148
- );
149
-
150
- const cellIds: string[] = tableInfo?.cells ?? [];
151
- let cellIndex = 0;
152
-
153
- for (let row = 0; row < rowSize; row++) {
154
- for (let col = 0; col < colSize; col++) {
155
- if (cellIndex >= cellIds.length) break;
156
-
157
- const cellId = cellIds[cellIndex];
158
- relatedIds.add(cellId);
159
-
160
- const cellBlock = blockMap.get(cellId);
161
- if (cellBlock?.block_type === TABLE_CELL_BLOCK_TYPE) {
162
- const childIds: string[] = cellBlock.children ?? [];
163
- const textParts: string[] = [];
164
-
165
- for (const childId of childIds) {
166
- relatedIds.add(childId);
167
- const textBlock = blockMap.get(childId);
168
- if (textBlock?.block_type === 2 && textBlock.text?.elements) {
169
- const text = textBlock.text.elements
170
- .filter((e: any) => e.text_run)
171
- .map((e: any) => e.text_run.content)
172
- .join("");
173
- textParts.push(text);
174
- }
175
- }
176
-
177
- cells[row][col] = textParts.join("\n");
178
- }
179
-
180
- cellIndex++;
181
- }
182
- }
183
-
184
- return { tableData: { rowSize, colSize, cells }, relatedIds };
185
- }
186
-
187
- /** Convert blocks to content items, preserving order with tables in correct positions */
188
- function convertBlocksToContentItems(blocks: any[]): ContentItem[] {
189
- const blockMap = new Map<string, any>();
190
- for (const block of blocks) {
191
- if (block.block_id) {
192
- blockMap.set(block.block_id, block);
193
- }
194
- }
195
-
196
- // Track which blocks are part of tables
197
- const tableRelatedIds = new Set<string>();
198
- const tableDataMap = new Map<string, TableData>();
199
-
200
- // First pass: extract all table data and mark related IDs
201
- for (const block of blocks) {
202
- if (block.block_type === TABLE_BLOCK_TYPE) {
203
- const { tableData, relatedIds } = extractTableData(block, blockMap);
204
- tableDataMap.set(block.block_id, tableData);
205
- for (const id of relatedIds) {
206
- tableRelatedIds.add(id);
207
- }
208
- }
209
- }
210
-
211
- // Second pass: build content items in order
212
- const items: ContentItem[] = [];
213
- for (const block of blocks) {
214
- if (tableRelatedIds.has(block.block_id)) {
215
- // If this is a table block itself, add the table item
216
- if (block.block_type === TABLE_BLOCK_TYPE) {
217
- const tableData = tableDataMap.get(block.block_id);
218
- if (tableData) {
219
- items.push({ type: "table", table: tableData });
220
- }
221
- }
222
- // Skip table-related blocks (cells, cell contents)
223
- } else {
224
- items.push({ type: "block", block });
225
- }
226
- }
227
-
228
- return items;
229
- }
230
-
231
- /** Extract table data from converted blocks (flat list with ID references) - legacy function */
232
- function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBlocks: any[] } {
233
- const items = convertBlocksToContentItems(blocks);
234
- const tables: TableData[] = [];
235
- const otherBlocks: any[] = [];
236
-
237
- for (const item of items) {
238
- if (item.type === "table") {
239
- tables.push(item.table);
240
- } else {
241
- otherBlocks.push(item.block);
242
- }
243
- }
244
77
 
245
- return { tables, otherBlocks };
246
- }
78
+ // ============ Helpers for New Implementation ============
247
79
 
248
- /** Clean blocks for insertion (remove read-only fields and unsupported nested structures) */
249
- function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
250
- const skipped: string[] = [];
251
- const cleaned = blocks.map((block) => {
252
- const { children, parent_id, block_id, ...rest } = block;
253
-
254
- // Remove children from non-table blocks (nested lists not supported in create API)
255
- // Table children are handled separately via table.cells
256
- if (block.block_type === TABLE_BLOCK_TYPE && block.table?.merge_info) {
257
- const { merge_info, ...tableRest } = block.table;
258
- return { ...rest, table: tableRest };
259
- }
80
+ /** Delay helper for rate limiting */
81
+ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
260
82
 
261
- return rest;
262
- });
263
- return { cleaned, skipped };
83
+ /** Generate unique block ID */
84
+ let blockIdCounter = 0;
85
+ function generateBlockId(): string {
86
+ blockIdCounter++;
87
+ return `tmp_${Date.now()}_${blockIdCounter}`;
264
88
  }
265
89
 
266
- // ============ Helpers for Recursive Insert ============
267
-
268
- /** Delay helper for rate limiting */
269
- const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
270
-
271
- /** API call with retry for rate limiting */
272
- async function createChildrenWithRetry(
90
+ /**
91
+ * Create nested blocks using the descendant API.
92
+ * This allows creating complex structures like tables in a single API call.
93
+ */
94
+ async function createNestedBlocks(
273
95
  client: Lark.Client,
274
96
  docToken: string,
275
97
  parentBlockId: string,
276
- children: any[],
98
+ childrenIds: string[],
99
+ descendants: any[],
277
100
  index?: number,
278
- retries = 3
279
- ): Promise<any> {
280
- for (let i = 0; i < retries; i++) {
281
- try {
282
- const res = await client.docx.documentBlockChildren.create({
283
- path: { document_id: docToken, block_id: parentBlockId },
284
- data: {
285
- children,
286
- ...(index !== undefined && { index })
287
- },
288
- });
289
- if (res.code === 0) return res;
290
- // Rate limit error
291
- if (res.code === 99991400 || res.msg?.includes('rate')) {
292
- await delay((i + 1) * 2000);
293
- continue;
294
- }
295
- return res;
296
- } catch (e: any) {
297
- if (e.message?.includes('429') || e.response?.status === 429) {
298
- await delay((i + 1) * 2000);
299
- continue;
300
- }
301
- throw e;
302
- }
303
- }
304
- return { code: -1, msg: 'Max retries exceeded' };
305
- }
306
-
307
- /** Context for recursive block insertion */
308
- interface InsertContext {
309
- client: Lark.Client;
310
- docToken: string;
311
- blockMap: Map<string, any>;
312
- tableRelatedIds: Set<string>;
313
- tableDataMap: Map<string, TableData>;
314
- insertedCount: number;
315
- tablesCreated: number;
316
- }
317
-
318
- /** Recursively insert a block and its children */
319
- async function insertBlockWithChildren(
320
- ctx: InsertContext,
321
- block: any,
322
- parentBlockId: string,
323
- index?: number
324
- ): Promise<string | null> {
325
- // Skip table-related blocks (handled separately)
326
- if (ctx.tableRelatedIds.has(block.block_id)) {
327
- if (block.block_type === TABLE_BLOCK_TYPE) {
328
- const tableData = ctx.tableDataMap.get(block.block_id);
329
- if (tableData) {
330
- return await createTableWithRetry(ctx, parentBlockId, tableData, index);
331
- }
332
- }
333
- return null;
334
- }
335
-
336
- // Clean block (remove read-only fields but preserve children reference)
337
- const { block_id, parent_id, children, ...cleanBlock } = block;
338
-
339
- // Add delay to avoid rate limiting
340
- await delay(100);
341
-
342
- // Insert current block
343
- const insertRes = await createChildrenWithRetry(
344
- ctx.client,
345
- ctx.docToken,
346
- parentBlockId,
347
- [cleanBlock],
348
- index
349
- );
350
-
351
- if (insertRes.code !== 0) {
352
- console.error(`Failed to insert block: ${insertRes.msg}`);
353
- return null;
354
- }
355
-
356
- const newBlockId = insertRes.data?.children?.[0]?.block_id;
357
- if (!newBlockId) return null;
358
-
359
- ctx.insertedCount++;
360
-
361
- // Recursively insert children
362
- if (children && children.length > 0) {
363
- for (let i = 0; i < children.length; i++) {
364
- const childId = children[i];
365
- const childBlock = ctx.blockMap.get(childId);
366
- if (childBlock) {
367
- await insertBlockWithChildren(ctx, childBlock, newBlockId, i);
368
- }
369
- }
370
- }
371
-
372
- return newBlockId;
373
- }
374
-
375
- /** Create and fill a table with retry */
376
- async function createTableWithRetry(
377
- ctx: InsertContext,
378
- parentBlockId: string,
379
- tableData: TableData,
380
- index?: number
381
- ): Promise<string | null> {
382
- await delay(100);
383
-
384
- const createRes = await createChildrenWithRetry(
385
- ctx.client,
386
- ctx.docToken,
387
- parentBlockId,
388
- [{
389
- block_type: TABLE_BLOCK_TYPE,
390
- table: {
391
- property: { row_size: tableData.rowSize, column_size: tableData.colSize, header_row: true },
392
- cells: []
393
- }
394
- }],
395
- index
396
- );
397
-
398
- if (createRes.code !== 0) {
399
- console.error(`Failed to create table: ${createRes.msg}`);
400
- return null;
401
- }
402
-
403
- const tableBlockId = createRes.data?.children?.[0]?.block_id;
404
- if (!tableBlockId) return null;
405
-
406
- // Fill table content
407
- const cellBlocksRes = await ctx.client.docx.documentBlockChildren.get({
408
- path: { document_id: ctx.docToken, block_id: tableBlockId },
101
+ ): Promise<void> {
102
+ const res = await client.docx.documentBlockDescendant.create({
103
+ path: { document_id: docToken, block_id: parentBlockId },
104
+ data: {
105
+ children_id: childrenIds,
106
+ descendants,
107
+ ...(index !== undefined && { index }),
108
+ },
409
109
  });
410
- const cellBlocks = cellBlocksRes.data?.items ?? [];
411
-
412
- let cellIndex = 0;
413
- for (let row = 0; row < tableData.rowSize; row++) {
414
- for (let col = 0; col < tableData.colSize; col++) {
415
- if (cellIndex >= cellBlocks.length) break;
416
- const cellBlockId = cellBlocks[cellIndex]?.block_id;
417
- const cellText = tableData.cells[row]?.[col] ?? "";
418
- if (cellBlockId && cellText) {
419
- await delay(50);
420
- const cellChildrenRes = await ctx.client.docx.documentBlockChildren.get({
421
- path: { document_id: ctx.docToken, block_id: cellBlockId },
422
- });
423
- const textBlockId = cellChildrenRes.data?.items?.[0]?.block_id;
424
- if (textBlockId) {
425
- await ctx.client.docx.documentBlock.patch({
426
- path: { document_id: ctx.docToken, block_id: textBlockId },
427
- data: { update_text_elements: { elements: [{ text_run: { content: cellText } }] } },
428
- });
429
- }
430
- }
431
- cellIndex++;
432
- }
433
- }
434
110
 
435
- ctx.tablesCreated++;
436
- return tableBlockId;
111
+ if (res.code !== 0) {
112
+ throw new Error(`Failed to create nested blocks: ${res.msg}`);
113
+ }
437
114
  }
438
115
 
439
- /** Insert blocks recursively preserving nested structure */
440
- async function insertBlocksRecursively(
116
+ /**
117
+ * Create a complete table using nested blocks API.
118
+ * No 9x9 size limitation.
119
+ */
120
+ async function createTableWithNestedBlocks(
441
121
  client: Lark.Client,
442
122
  docToken: string,
443
- blocks: any[],
444
- firstLevelBlockIds: string[],
445
- startIndex: number = 0
446
- ): Promise<{ blocksInserted: number; tablesCreated: number }> {
447
- // Build block map
448
- const blockMap = new Map<string, any>();
449
- for (const block of blocks) {
450
- if (block.block_id) {
451
- blockMap.set(block.block_id, block);
452
- }
453
- }
123
+ rows: string[][],
124
+ index?: number,
125
+ ): Promise<void> {
126
+ if (rows.length === 0) return;
454
127
 
455
- // Extract table data and related IDs
456
- const tableRelatedIds = new Set<string>();
457
- const tableDataMap = new Map<string, TableData>();
128
+ const rowSize = rows.length;
129
+ const colSize = Math.max(...rows.map((r) => r.length));
458
130
 
459
- for (const block of blocks) {
460
- if (block.block_type === TABLE_BLOCK_TYPE) {
461
- const { tableData, relatedIds } = extractTableData(block, blockMap);
462
- tableDataMap.set(block.block_id, tableData);
463
- for (const id of relatedIds) {
464
- tableRelatedIds.add(id);
465
- }
466
- }
467
- }
131
+ // Generate IDs
132
+ const tableId = generateBlockId();
133
+ const cellIds: string[] = [];
134
+ const descendants: any[] = [];
468
135
 
469
- const ctx: InsertContext = {
470
- client,
471
- docToken,
472
- blockMap,
473
- tableRelatedIds,
474
- tableDataMap,
475
- insertedCount: 0,
476
- tablesCreated: 0,
477
- };
478
-
479
- // Insert blocks in order of first_level_block_ids
480
- for (let i = 0; i < firstLevelBlockIds.length; i++) {
481
- const blockId = firstLevelBlockIds[i];
482
- const block = blockMap.get(blockId);
483
- if (block) {
484
- await insertBlockWithChildren(ctx, block, docToken, startIndex + i);
136
+ // Create cell IDs array
137
+ for (let row = 0; row < rowSize; row++) {
138
+ for (let col = 0; col < colSize; col++) {
139
+ cellIds.push(generateBlockId());
485
140
  }
486
141
  }
487
142
 
488
- return { blocksInserted: ctx.insertedCount, tablesCreated: ctx.tablesCreated };
489
- }
490
-
491
- // ============ Core Functions ============
492
-
493
- /** Convert markdown to Feishu blocks using the Convert API */
494
- async function convertMarkdown(client: Lark.Client, markdown: string) {
495
- const res = await client.docx.document.convert({
496
- data: { content_type: "markdown", content: markdown },
143
+ // 1. Table block
144
+ descendants.push({
145
+ block_id: tableId,
146
+ block_type: TABLE_BLOCK_TYPE,
147
+ table: {
148
+ property: {
149
+ row_size: rowSize,
150
+ column_size: colSize,
151
+ header_row: true,
152
+ },
153
+ },
154
+ children: cellIds,
497
155
  });
498
- if (res.code !== 0) throw new Error(res.msg);
499
156
 
500
- const rawBlocks = res.data?.blocks ?? [];
501
- const firstLevelBlockIds = res.data?.first_level_block_ids ?? [];
157
+ // 2. Table cells and their content
158
+ let cellIdx = 0;
159
+ for (let row = 0; row < rowSize; row++) {
160
+ for (let col = 0; col < colSize; col++) {
161
+ const cellId = cellIds[cellIdx];
162
+ const textId = generateBlockId();
163
+ const cellContent = rows[row]?.[col] ?? "";
164
+
165
+ // Table cell block
166
+ descendants.push({
167
+ block_id: cellId,
168
+ block_type: 32, // TableCell
169
+ table_cell: {},
170
+ children: [textId],
171
+ });
172
+
173
+ // Text block inside cell
174
+ descendants.push({
175
+ block_id: textId,
176
+ block_type: 2, // Text
177
+ text: {
178
+ elements: [{ text_run: { content: cellContent } }],
179
+ },
180
+ children: [],
181
+ });
502
182
 
503
- // Sort blocks according to first_level_block_ids (Convert API returns blocks in arbitrary order)
504
- const blocks = sortBlocksByFirstLevelIds(rawBlocks, firstLevelBlockIds);
183
+ cellIdx++;
184
+ }
185
+ }
505
186
 
506
- return { blocks, firstLevelBlockIds };
187
+ await createNestedBlocks(client, docToken, docToken, [tableId], descendants, index);
507
188
  }
508
189
 
509
- /** Insert blocks as children of a parent block (with batching for >50 blocks) */
510
- async function insertBlocks(
190
+ /**
191
+ * Insert regular blocks (non-table) in batches.
192
+ */
193
+ async function insertBlocksBatch(
511
194
  client: Lark.Client,
512
195
  docToken: string,
513
196
  blocks: any[],
514
- parentBlockId?: string,
515
- ): Promise<{ children: any[]; skipped: string[] }> {
516
- const { cleaned, skipped } = cleanBlocksForInsert(blocks);
517
- const blockId = parentBlockId ?? docToken;
518
-
519
- if (cleaned.length === 0) {
520
- return { children: [], skipped };
521
- }
197
+ index?: number,
198
+ ): Promise<any[]> {
199
+ if (blocks.length === 0) return [];
522
200
 
523
- // Feishu API limits to 50 blocks per request
524
201
  const BATCH_SIZE = 50;
525
202
  const allChildren: any[] = [];
203
+ let insertIndex = index ?? 0;
526
204
 
527
- // Get current children count to determine insert index
528
- let insertIndex = 0;
529
- const existing = await client.docx.documentBlockChildren.get({
530
- path: { document_id: docToken, block_id: blockId },
531
- });
532
- if (existing.code === 0) {
533
- insertIndex = existing.data?.items?.length ?? 0;
534
- }
535
-
536
- for (let i = 0; i < cleaned.length; i += BATCH_SIZE) {
537
- const batch = cleaned.slice(i, i + BATCH_SIZE);
538
- const res = await client.docx.documentBlockChildren.create({
539
- path: { document_id: docToken, block_id: blockId },
540
- data: { children: batch, index: insertIndex },
205
+ // If no index specified, append at end
206
+ if (index === undefined) {
207
+ const existing = await client.docx.documentBlockChildren.get({
208
+ path: { document_id: docToken, block_id: docToken },
541
209
  });
542
- if (res.code !== 0) throw new Error(res.msg);
543
- const inserted = res.data?.children ?? [];
544
- allChildren.push(...inserted);
545
- insertIndex += inserted.length;
546
- }
547
-
548
- return { children: allChildren, skipped };
549
- }
550
-
551
- /** Insert blocks at a specific index (with batching for >50 blocks) */
552
- async function insertBlocksAtIndex(
553
- client: Lark.Client,
554
- docToken: string,
555
- blocks: any[],
556
- startIndex: number,
557
- parentBlockId?: string,
558
- ): Promise<{ children: any[]; skipped: string[] }> {
559
- const { cleaned, skipped } = cleanBlocksForInsert(blocks);
560
- const blockId = parentBlockId ?? docToken;
561
-
562
- if (cleaned.length === 0) {
563
- return { children: [], skipped };
210
+ if (existing.code === 0) {
211
+ insertIndex = existing.data?.items?.length ?? 0;
212
+ }
564
213
  }
565
214
 
566
- const BATCH_SIZE = 50;
567
- const allChildren: any[] = [];
568
- let insertIndex = startIndex;
569
-
570
- for (let i = 0; i < cleaned.length; i += BATCH_SIZE) {
571
- const batch = cleaned.slice(i, i + BATCH_SIZE);
215
+ for (let i = 0; i < blocks.length; i += BATCH_SIZE) {
216
+ const batch = blocks.slice(i, i + BATCH_SIZE);
572
217
  const res = await client.docx.documentBlockChildren.create({
573
- path: { document_id: docToken, block_id: blockId },
218
+ path: { document_id: docToken, block_id: docToken },
574
219
  data: { children: batch, index: insertIndex },
575
220
  });
576
221
  if (res.code !== 0) throw new Error(res.msg);
@@ -579,9 +224,11 @@ async function insertBlocksAtIndex(
579
224
  insertIndex += inserted.length;
580
225
  }
581
226
 
582
- return { children: allChildren, skipped };
227
+ return allChildren;
583
228
  }
584
229
 
230
+ // ============ Core Functions ============
231
+
585
232
  /** Delete all child blocks from a parent */
586
233
  async function clearDocumentContent(client: Lark.Client, docToken: string) {
587
234
  const existing = await client.docx.documentBlock.list({
@@ -638,168 +285,6 @@ async function downloadImage(url: string): Promise<Buffer> {
638
285
  return Buffer.from(await response.arrayBuffer());
639
286
  }
640
287
 
641
- /** Create an empty table and return its block ID */
642
- async function createEmptyTable(
643
- client: Lark.Client,
644
- docToken: string,
645
- parentBlockId: string,
646
- rowSize: number,
647
- colSize: number,
648
- index?: number,
649
- ): Promise<string | null> {
650
- const tableBlock = {
651
- block_type: TABLE_BLOCK_TYPE,
652
- table: {
653
- property: {
654
- row_size: rowSize,
655
- column_size: colSize,
656
- header_row: true,
657
- },
658
- cells: [], // Empty cells, will be filled later
659
- },
660
- };
661
-
662
- const res = await client.docx.documentBlockChildren.create({
663
- path: { document_id: docToken, block_id: parentBlockId },
664
- data: { children: [tableBlock], ...(index !== undefined && { index }) },
665
- });
666
-
667
- if (res.code !== 0) {
668
- console.error(`Failed to create table: ${res.msg}`);
669
- return null;
670
- }
671
-
672
- const created = res.data?.children ?? [];
673
- return created[0]?.block_id ?? null;
674
- }
675
-
676
- /** Get children blocks of a parent block */
677
- async function getBlockChildren(
678
- client: Lark.Client,
679
- docToken: string,
680
- blockId: string,
681
- ): Promise<any[]> {
682
- const res = await client.docx.documentBlockChildren.get({
683
- path: { document_id: docToken, block_id: blockId },
684
- });
685
- if (res.code !== 0) {
686
- throw new Error(`Failed to get block children: ${res.msg}`);
687
- }
688
- return res.data?.items ?? [];
689
- }
690
-
691
- /** Update a text block's content */
692
- async function updateTextBlock(
693
- client: Lark.Client,
694
- docToken: string,
695
- blockId: string,
696
- content: string,
697
- ): Promise<void> {
698
- const res = await client.docx.documentBlock.patch({
699
- path: { document_id: docToken, block_id: blockId },
700
- data: {
701
- update_text_elements: {
702
- elements: [{ text_run: { content } }],
703
- },
704
- },
705
- });
706
- if (res.code !== 0) {
707
- console.error(`Failed to update text block ${blockId}: ${res.msg}`);
708
- }
709
- }
710
-
711
- /** Fill table cells with content */
712
- async function fillTableContent(
713
- client: Lark.Client,
714
- docToken: string,
715
- tableBlockId: string,
716
- cells: string[][],
717
- ): Promise<void> {
718
- // Get table's cell blocks (they are direct children of the table)
719
- const cellBlocks = await getBlockChildren(client, docToken, tableBlockId);
720
-
721
- // Cells are in row-major order
722
- const colSize = cells[0]?.length ?? 0;
723
- let cellIndex = 0;
724
-
725
- for (let row = 0; row < cells.length; row++) {
726
- for (let col = 0; col < colSize; col++) {
727
- if (cellIndex >= cellBlocks.length) break;
728
-
729
- const cellBlock = cellBlocks[cellIndex];
730
- const cellBlockId = cellBlock?.block_id;
731
- const cellText = cells[row]?.[col] ?? "";
732
-
733
- if (cellBlockId && cellText) {
734
- // Each cell block contains text blocks as children
735
- const cellChildren = await getBlockChildren(client, docToken, cellBlockId);
736
- if (cellChildren.length > 0) {
737
- // Update the first text block in the cell
738
- const textBlockId = cellChildren[0]?.block_id;
739
- if (textBlockId) {
740
- await updateTextBlock(client, docToken, textBlockId, cellText);
741
- }
742
- }
743
- }
744
-
745
- cellIndex++;
746
- }
747
- }
748
- }
749
-
750
- /** Create and fill a table */
751
- async function createAndFillTable(
752
- client: Lark.Client,
753
- docToken: string,
754
- parentBlockId: string,
755
- tableData: TableData,
756
- ): Promise<boolean> {
757
- // 1. Create empty table
758
- const tableBlockId = await createEmptyTable(
759
- client,
760
- docToken,
761
- parentBlockId,
762
- tableData.rowSize,
763
- tableData.colSize,
764
- );
765
-
766
- if (!tableBlockId) {
767
- return false;
768
- }
769
-
770
- // 2. Fill table content
771
- await fillTableContent(client, docToken, tableBlockId, tableData.cells);
772
-
773
- return true;
774
- }
775
-
776
- /** Create and fill a table at a specific index */
777
- async function createAndFillTableAtIndex(
778
- client: Lark.Client,
779
- docToken: string,
780
- parentBlockId: string,
781
- tableData: TableData,
782
- index: number,
783
- ): Promise<boolean> {
784
- // 1. Create empty table at specific index
785
- const tableBlockId = await createEmptyTable(
786
- client,
787
- docToken,
788
- parentBlockId,
789
- tableData.rowSize,
790
- tableData.colSize,
791
- index,
792
- );
793
-
794
- if (!tableBlockId) {
795
- return false;
796
- }
797
-
798
- // 2. Fill table content
799
- await fillTableContent(client, docToken, tableBlockId, tableData.cells);
800
-
801
- return true;
802
- }
803
288
 
804
289
  /** Process images in markdown: download from URL, upload to Feishu, update blocks */
805
290
  async function processImages(
@@ -1035,20 +520,46 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
1035
520
  // 1. Clear existing content
1036
521
  const deleted = await clearDocumentContent(client, docToken);
1037
522
 
1038
- // 2. Convert markdown to blocks
1039
- const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
1040
- if (blocks.length === 0) {
523
+ // 2. Parse markdown locally (no Convert API)
524
+ const { blocks, tables, allItems } = markdownToFeishuBlocks(markdown);
525
+ if (allItems.length === 0) {
1041
526
  return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0, tables_created: 0 };
1042
527
  }
1043
528
 
1044
- // 3. Insert blocks recursively (preserves nested structure)
1045
- const { blocksInserted, tablesCreated } = await insertBlocksRecursively(
1046
- client,
1047
- docToken,
1048
- blocks,
1049
- firstLevelBlockIds,
1050
- 0
1051
- );
529
+ // 3. Insert blocks in order, handling tables specially
530
+ let blocksInserted = 0;
531
+ let tablesCreated = 0;
532
+ let currentIndex = 0;
533
+
534
+ // Collect regular blocks to batch insert
535
+ let regularBlocksBatch: any[] = [];
536
+
537
+ for (const item of allItems) {
538
+ if (item instanceof TablePlaceholder) {
539
+ // First, insert any accumulated regular blocks
540
+ if (regularBlocksBatch.length > 0) {
541
+ const inserted = await insertBlocksBatch(client, docToken, regularBlocksBatch, currentIndex);
542
+ blocksInserted += inserted.length;
543
+ currentIndex += inserted.length;
544
+ regularBlocksBatch = [];
545
+ }
546
+
547
+ // Then create the table
548
+ await createTableWithNestedBlocks(client, docToken, item.rows, currentIndex);
549
+ tablesCreated++;
550
+ currentIndex++;
551
+ } else {
552
+ // Accumulate regular blocks for batch insert
553
+ const apiBlock = prepareBlocksForApi([item])[0];
554
+ regularBlocksBatch.push(apiBlock);
555
+ }
556
+ }
557
+
558
+ // Insert any remaining regular blocks
559
+ if (regularBlocksBatch.length > 0) {
560
+ const inserted = await insertBlocksBatch(client, docToken, regularBlocksBatch, currentIndex);
561
+ blocksInserted += inserted.length;
562
+ }
1052
563
 
1053
564
  // 4. Process images (get inserted blocks for image processing)
1054
565
  const insertedRes = await client.docx.documentBlock.list({
@@ -1068,9 +579,9 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
1068
579
  }
1069
580
 
1070
581
  async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
1071
- // 1. Convert markdown to blocks
1072
- const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
1073
- if (blocks.length === 0) {
582
+ // 1. Parse markdown locally (no Convert API)
583
+ const { blocks, tables, allItems } = markdownToFeishuBlocks(markdown);
584
+ if (allItems.length === 0) {
1074
585
  throw new Error("Content is empty");
1075
586
  }
1076
587
 
@@ -1083,14 +594,40 @@ async function appendDoc(client: Lark.Client, docToken: string, markdown: string
1083
594
  startIndex = existing.data?.items?.length ?? 0;
1084
595
  }
1085
596
 
1086
- // 3. Insert blocks recursively (preserves nested structure)
1087
- const { blocksInserted, tablesCreated } = await insertBlocksRecursively(
1088
- client,
1089
- docToken,
1090
- blocks,
1091
- firstLevelBlockIds,
1092
- startIndex
1093
- );
597
+ // 3. Insert blocks in order, handling tables specially
598
+ let blocksInserted = 0;
599
+ let tablesCreated = 0;
600
+ let currentIndex = startIndex;
601
+
602
+ // Collect regular blocks to batch insert
603
+ let regularBlocksBatch: any[] = [];
604
+
605
+ for (const item of allItems) {
606
+ if (item instanceof TablePlaceholder) {
607
+ // First, insert any accumulated regular blocks
608
+ if (regularBlocksBatch.length > 0) {
609
+ const inserted = await insertBlocksBatch(client, docToken, regularBlocksBatch, currentIndex);
610
+ blocksInserted += inserted.length;
611
+ currentIndex += inserted.length;
612
+ regularBlocksBatch = [];
613
+ }
614
+
615
+ // Then create the table
616
+ await createTableWithNestedBlocks(client, docToken, item.rows, currentIndex);
617
+ tablesCreated++;
618
+ currentIndex++;
619
+ } else {
620
+ // Accumulate regular blocks for batch insert
621
+ const apiBlock = prepareBlocksForApi([item])[0];
622
+ regularBlocksBatch.push(apiBlock);
623
+ }
624
+ }
625
+
626
+ // Insert any remaining regular blocks
627
+ if (regularBlocksBatch.length > 0) {
628
+ const inserted = await insertBlocksBatch(client, docToken, regularBlocksBatch, currentIndex);
629
+ blocksInserted += inserted.length;
630
+ }
1094
631
 
1095
632
  // 4. Process images (get inserted blocks for image processing)
1096
633
  const insertedRes = await client.docx.documentBlock.list({
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Feishu Document Renderer using marked.
3
+ *
4
+ * Converts Markdown AST to Feishu Block structure.
5
+ */
6
+
7
+ import { Lexer, type Token, type Tokens } from "marked";
8
+
9
+ // Code language mapping for Feishu
10
+ export const CODE_LANG_MAP: Record<string, number> = {
11
+ python: 49,
12
+ py: 49,
13
+ javascript: 30,
14
+ js: 30,
15
+ typescript: 63,
16
+ ts: 63,
17
+ go: 22,
18
+ golang: 22,
19
+ rust: 53,
20
+ java: 29,
21
+ kotlin: 32,
22
+ kt: 32,
23
+ c: 10,
24
+ cpp: 9,
25
+ "c++": 9,
26
+ sql: 56,
27
+ bash: 7,
28
+ shell: 7,
29
+ sh: 7,
30
+ json: 28,
31
+ yaml: 67,
32
+ yml: 67,
33
+ markdown: 39,
34
+ md: 39,
35
+ html: 24,
36
+ css: 12,
37
+ swift: 61,
38
+ ruby: 52,
39
+ php: 43,
40
+ };
41
+
42
+ /**
43
+ * Placeholder for table content.
44
+ *
45
+ * Tables in Feishu require special handling via nested blocks API.
46
+ */
47
+ export class TablePlaceholder {
48
+ rows: string[][];
49
+ rowSize: number;
50
+ colSize: number;
51
+
52
+ constructor(rows: string[][]) {
53
+ this.rows = rows;
54
+ this.rowSize = rows.length;
55
+ this.colSize = rows.length > 0 ? Math.max(...rows.map((r) => r.length)) : 0;
56
+ }
57
+
58
+ toString(): string {
59
+ return `TablePlaceholder(${this.rowSize}x${this.colSize})`;
60
+ }
61
+ }
62
+
63
+ /** Feishu text element */
64
+ export interface TextElement {
65
+ text_run: {
66
+ content: string;
67
+ text_element_style?: {
68
+ bold?: boolean;
69
+ italic?: boolean;
70
+ strikethrough?: boolean;
71
+ inline_code?: boolean;
72
+ link?: { url: string };
73
+ };
74
+ };
75
+ }
76
+
77
+ /** Feishu block structure */
78
+ export interface FeishuBlock {
79
+ block_id: string;
80
+ block_type: number;
81
+ children: string[];
82
+ text?: { elements: TextElement[] };
83
+ heading1?: { elements: TextElement[] };
84
+ heading2?: { elements: TextElement[] };
85
+ heading3?: { elements: TextElement[] };
86
+ heading4?: { elements: TextElement[] };
87
+ heading5?: { elements: TextElement[] };
88
+ heading6?: { elements: TextElement[] };
89
+ heading7?: { elements: TextElement[] };
90
+ heading8?: { elements: TextElement[] };
91
+ heading9?: { elements: TextElement[] };
92
+ bullet?: { elements: TextElement[] };
93
+ ordered?: { elements: TextElement[] };
94
+ code?: { elements: TextElement[]; style: { language: number } };
95
+ quote_container?: Record<string, never>;
96
+ divider?: Record<string, never>;
97
+ _nested_blocks?: FeishuBlock[];
98
+ }
99
+
100
+ /** Content item: either a block or a table placeholder */
101
+ export type ContentItem = FeishuBlock | TablePlaceholder;
102
+
103
+ /**
104
+ * Render Markdown to Feishu Block structure.
105
+ */
106
+ export class FeishuRenderer {
107
+ private blockId = 0;
108
+
109
+ private nextId(): string {
110
+ this.blockId++;
111
+ return `block_${this.blockId}`;
112
+ }
113
+
114
+ /**
115
+ * Render markdown tokens to Feishu blocks.
116
+ */
117
+ render(tokens: Token[]): ContentItem[] {
118
+ const result: ContentItem[] = [];
119
+ for (const token of tokens) {
120
+ const block = this.renderToken(token);
121
+ if (block !== null) {
122
+ if (Array.isArray(block)) {
123
+ result.push(...block);
124
+ } else {
125
+ result.push(block);
126
+ }
127
+ }
128
+ }
129
+ return result;
130
+ }
131
+
132
+ private renderToken(token: Token): ContentItem | ContentItem[] | null {
133
+ switch (token.type) {
134
+ case "paragraph":
135
+ return this.paragraph(token as Tokens.Paragraph);
136
+ case "heading":
137
+ return this.heading(token as Tokens.Heading);
138
+ case "code":
139
+ return this.blockCode(token as Tokens.Code);
140
+ case "blockquote":
141
+ return this.blockQuote(token as Tokens.Blockquote);
142
+ case "list":
143
+ return this.list(token as Tokens.List);
144
+ case "hr":
145
+ return this.thematicBreak();
146
+ case "table":
147
+ return this.table(token as Tokens.Table);
148
+ case "space":
149
+ return null;
150
+ default:
151
+ return null;
152
+ }
153
+ }
154
+
155
+ // === Block-level rendering ===
156
+
157
+ private paragraph(token: Tokens.Paragraph): FeishuBlock {
158
+ const elements = this.renderInlineTokens(token.tokens ?? []);
159
+ return {
160
+ block_id: this.nextId(),
161
+ block_type: 2,
162
+ text: { elements },
163
+ children: [],
164
+ };
165
+ }
166
+
167
+ private heading(token: Tokens.Heading): FeishuBlock {
168
+ const level = token.depth;
169
+ const elements = this.renderInlineTokens(token.tokens ?? []);
170
+ const key = `heading${level}` as keyof FeishuBlock;
171
+ return {
172
+ block_id: this.nextId(),
173
+ block_type: 2 + level, // heading1=3, heading2=4, etc.
174
+ [key]: { elements },
175
+ children: [],
176
+ };
177
+ }
178
+
179
+ private blockCode(token: Tokens.Code): FeishuBlock {
180
+ const lang = token.lang ?? "";
181
+ const langCode = CODE_LANG_MAP[lang.toLowerCase()] ?? 1;
182
+ return {
183
+ block_id: this.nextId(),
184
+ block_type: 14,
185
+ code: {
186
+ elements: [{ text_run: { content: token.text } }],
187
+ style: { language: langCode },
188
+ },
189
+ children: [],
190
+ };
191
+ }
192
+
193
+ private blockQuote(token: Tokens.Blockquote): FeishuBlock {
194
+ const containerId = this.nextId();
195
+ const nestedBlocks: FeishuBlock[] = [];
196
+
197
+ for (const child of token.tokens ?? []) {
198
+ const block = this.renderToken(child);
199
+ if (block !== null) {
200
+ if (Array.isArray(block)) {
201
+ for (const b of block) {
202
+ if (!(b instanceof TablePlaceholder)) {
203
+ nestedBlocks.push(b);
204
+ }
205
+ }
206
+ } else if (!(block instanceof TablePlaceholder)) {
207
+ nestedBlocks.push(block);
208
+ }
209
+ }
210
+ }
211
+
212
+ return {
213
+ block_id: containerId,
214
+ block_type: 34, // quote_container
215
+ quote_container: {},
216
+ children: nestedBlocks.map((b) => b.block_id),
217
+ _nested_blocks: nestedBlocks,
218
+ };
219
+ }
220
+
221
+ private list(token: Tokens.List): FeishuBlock[] {
222
+ const ordered = token.ordered;
223
+ const blockType = ordered ? 13 : 12;
224
+ const key = ordered ? "ordered" : "bullet";
225
+
226
+ const result: FeishuBlock[] = [];
227
+ for (const item of token.items) {
228
+ const elements = this.renderListItemContent(item);
229
+ result.push({
230
+ block_id: this.nextId(),
231
+ block_type: blockType,
232
+ [key]: { elements: elements.length > 0 ? elements : [{ text_run: { content: "" } }] },
233
+ children: [],
234
+ });
235
+ }
236
+ return result;
237
+ }
238
+
239
+ private renderListItemContent(item: Tokens.ListItem): TextElement[] {
240
+ const elements: TextElement[] = [];
241
+ for (const child of item.tokens ?? []) {
242
+ if (child.type === "text") {
243
+ const textToken = child as Tokens.Text;
244
+ if (textToken.tokens) {
245
+ elements.push(...this.renderInlineTokens(textToken.tokens));
246
+ } else {
247
+ elements.push({ text_run: { content: textToken.raw } });
248
+ }
249
+ } else if (child.type === "paragraph") {
250
+ const paraToken = child as Tokens.Paragraph;
251
+ elements.push(...this.renderInlineTokens(paraToken.tokens ?? []));
252
+ }
253
+ }
254
+ return elements;
255
+ }
256
+
257
+ private thematicBreak(): FeishuBlock {
258
+ return {
259
+ block_id: this.nextId(),
260
+ block_type: 22,
261
+ divider: {},
262
+ children: [],
263
+ };
264
+ }
265
+
266
+ // === Table rendering ===
267
+
268
+ private table(token: Tokens.Table): TablePlaceholder {
269
+ const rows: string[][] = [];
270
+
271
+ // Header row
272
+ const headerRow: string[] = [];
273
+ for (const cell of token.header) {
274
+ headerRow.push(this.extractTextContent(cell.tokens));
275
+ }
276
+ rows.push(headerRow);
277
+
278
+ // Body rows
279
+ for (const row of token.rows) {
280
+ const rowData: string[] = [];
281
+ for (const cell of row) {
282
+ rowData.push(this.extractTextContent(cell.tokens));
283
+ }
284
+ rows.push(rowData);
285
+ }
286
+
287
+ return new TablePlaceholder(rows);
288
+ }
289
+
290
+ private extractTextContent(tokens: Token[]): string {
291
+ const parts: string[] = [];
292
+ for (const token of tokens) {
293
+ if (token.type === "text") {
294
+ parts.push((token as Tokens.Text).text);
295
+ } else if (token.type === "codespan") {
296
+ parts.push((token as Tokens.Codespan).text);
297
+ } else if (token.type === "strong" || token.type === "em" || token.type === "del") {
298
+ const t = token as Tokens.Strong | Tokens.Em | Tokens.Del;
299
+ parts.push(this.extractTextContent(t.tokens ?? []));
300
+ } else if (token.type === "link") {
301
+ const linkToken = token as Tokens.Link;
302
+ parts.push(this.extractTextContent(linkToken.tokens ?? []));
303
+ }
304
+ }
305
+ return parts.join("");
306
+ }
307
+
308
+ // === Inline-level rendering ===
309
+
310
+ private renderInlineTokens(tokens: Token[]): TextElement[] {
311
+ const elements: TextElement[] = [];
312
+ for (const token of tokens) {
313
+ const result = this.renderInline(token);
314
+ if (result) {
315
+ if (Array.isArray(result)) {
316
+ elements.push(...result);
317
+ } else {
318
+ elements.push(result);
319
+ }
320
+ }
321
+ }
322
+ return elements.length > 0 ? elements : [{ text_run: { content: "" } }];
323
+ }
324
+
325
+ private renderInline(token: Token): TextElement | TextElement[] | null {
326
+ switch (token.type) {
327
+ case "text":
328
+ return { text_run: { content: (token as Tokens.Text).text } };
329
+
330
+ case "strong": {
331
+ const strongToken = token as Tokens.Strong;
332
+ const elements = this.renderInlineTokens(strongToken.tokens ?? []);
333
+ for (const el of elements) {
334
+ el.text_run.text_element_style = el.text_run.text_element_style ?? {};
335
+ el.text_run.text_element_style.bold = true;
336
+ }
337
+ return elements;
338
+ }
339
+
340
+ case "em": {
341
+ const emToken = token as Tokens.Em;
342
+ const elements = this.renderInlineTokens(emToken.tokens ?? []);
343
+ for (const el of elements) {
344
+ el.text_run.text_element_style = el.text_run.text_element_style ?? {};
345
+ el.text_run.text_element_style.italic = true;
346
+ }
347
+ return elements;
348
+ }
349
+
350
+ case "del": {
351
+ const delToken = token as Tokens.Del;
352
+ const elements = this.renderInlineTokens(delToken.tokens ?? []);
353
+ for (const el of elements) {
354
+ el.text_run.text_element_style = el.text_run.text_element_style ?? {};
355
+ el.text_run.text_element_style.strikethrough = true;
356
+ }
357
+ return elements;
358
+ }
359
+
360
+ case "codespan":
361
+ return {
362
+ text_run: {
363
+ content: (token as Tokens.Codespan).text,
364
+ text_element_style: { inline_code: true },
365
+ },
366
+ };
367
+
368
+ case "link": {
369
+ const linkToken = token as Tokens.Link;
370
+ const elements = this.renderInlineTokens(linkToken.tokens ?? []);
371
+ const encodedUrl = encodeURIComponent(linkToken.href);
372
+ for (const el of elements) {
373
+ el.text_run.text_element_style = el.text_run.text_element_style ?? {};
374
+ el.text_run.text_element_style.link = { url: encodedUrl };
375
+ }
376
+ return elements;
377
+ }
378
+
379
+ case "br":
380
+ return { text_run: { content: "\n" } };
381
+
382
+ case "html":
383
+ return { text_run: { content: (token as Tokens.HTML).raw } };
384
+
385
+ default:
386
+ return null;
387
+ }
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Convert Markdown to Feishu Block structure.
393
+ *
394
+ * @param mdContent Markdown content string
395
+ * @returns Object with blocks array and tables array
396
+ */
397
+ export function markdownToFeishuBlocks(mdContent: string): {
398
+ blocks: FeishuBlock[];
399
+ tables: TablePlaceholder[];
400
+ allItems: ContentItem[];
401
+ } {
402
+ const lexer = new Lexer();
403
+ const tokens = lexer.lex(mdContent);
404
+
405
+ const renderer = new FeishuRenderer();
406
+ const items = renderer.render(tokens);
407
+
408
+ const blocks: FeishuBlock[] = [];
409
+ const tables: TablePlaceholder[] = [];
410
+
411
+ function collectBlocks(item: ContentItem) {
412
+ if (item instanceof TablePlaceholder) {
413
+ tables.push(item);
414
+ } else {
415
+ blocks.push(item);
416
+ // Collect nested blocks (e.g., from blockquote)
417
+ if (item._nested_blocks) {
418
+ for (const nested of item._nested_blocks) {
419
+ blocks.push(nested);
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ for (const item of items) {
426
+ collectBlocks(item);
427
+ }
428
+
429
+ return { blocks, tables, allItems: items };
430
+ }
431
+
432
+ /**
433
+ * Prepare blocks for Feishu API.
434
+ * Removes helper fields before sending to API.
435
+ */
436
+ export function prepareBlocksForApi(blocks: FeishuBlock[]): any[] {
437
+ return blocks.map((block) => {
438
+ const { block_id, children, _nested_blocks, ...rest } = block;
439
+ return rest;
440
+ });
441
+ }