@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 +2 -1
- package/src/docx.ts +189 -652
- package/src/feishu-renderer.ts +441 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max1874/feishu",
|
|
3
|
-
"version": "0.2.
|
|
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
|
|
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
|
-
|
|
246
|
-
}
|
|
78
|
+
// ============ Helpers for New Implementation ============
|
|
247
79
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
98
|
+
childrenIds: string[],
|
|
99
|
+
descendants: any[],
|
|
277
100
|
index?: number,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
436
|
-
|
|
111
|
+
if (res.code !== 0) {
|
|
112
|
+
throw new Error(`Failed to create nested blocks: ${res.msg}`);
|
|
113
|
+
}
|
|
437
114
|
}
|
|
438
115
|
|
|
439
|
-
/**
|
|
440
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
456
|
-
const
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
-
|
|
504
|
-
|
|
183
|
+
cellIdx++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
505
186
|
|
|
506
|
-
|
|
187
|
+
await createNestedBlocks(client, docToken, docToken, [tableId], descendants, index);
|
|
507
188
|
}
|
|
508
189
|
|
|
509
|
-
/**
|
|
510
|
-
|
|
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
|
-
|
|
515
|
-
): Promise<
|
|
516
|
-
|
|
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
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 (
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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:
|
|
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
|
|
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.
|
|
1039
|
-
const { blocks,
|
|
1040
|
-
if (
|
|
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
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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.
|
|
1072
|
-
const { blocks,
|
|
1073
|
-
if (
|
|
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
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
+
}
|