@max1874/feishu 0.2.10 → 0.2.12

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/docx.ts +299 -91
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max1874/feishu",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Feishu/Lark channel plugin",
6
6
  "license": "MIT",
package/src/docx.ts CHANGED
@@ -43,6 +43,60 @@ function extractImageUrls(markdown: string): string[] {
43
43
  return urls;
44
44
  }
45
45
 
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
+
46
100
  const BLOCK_TYPE_NAMES: Record<number, string> = {
47
101
  1: "Page",
48
102
  2: "Text",
@@ -75,12 +129,63 @@ interface TableData {
75
129
  cells: string[][]; // 2D array of cell text content
76
130
  }
77
131
 
78
- /** Extract table data from converted blocks (flat list with ID references) */
79
- function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBlocks: any[] } {
80
- const tables: TableData[] = [];
81
- const otherBlocks: any[] = [];
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
+ );
82
149
 
83
- // Build a map of block_id -> block for quick lookup
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[] {
84
189
  const blockMap = new Map<string, any>();
85
190
  for (const block of blocks) {
86
191
  if (block.block_id) {
@@ -88,69 +193,52 @@ function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBloc
88
193
  }
89
194
  }
90
195
 
91
- // Track which blocks are part of tables (to exclude from otherBlocks)
196
+ // Track which blocks are part of tables
92
197
  const tableRelatedIds = new Set<string>();
198
+ const tableDataMap = new Map<string, TableData>();
93
199
 
200
+ // First pass: extract all table data and mark related IDs
94
201
  for (const block of blocks) {
95
202
  if (block.block_type === TABLE_BLOCK_TYPE) {
96
- tableRelatedIds.add(block.block_id);
97
-
98
- const tableInfo = block.table;
99
- if (tableInfo) {
100
- const rowSize = tableInfo.property?.row_size ?? 0;
101
- const colSize = tableInfo.property?.column_size ?? 0;
102
-
103
- // Initialize cells array
104
- const cells: string[][] = Array.from({ length: rowSize }, () =>
105
- Array.from({ length: colSize }, () => ""),
106
- );
107
-
108
- // table.cells is a flat array of cell block IDs in row-major order
109
- const cellIds: string[] = tableInfo.cells ?? [];
110
-
111
- let cellIndex = 0;
112
- for (let row = 0; row < rowSize; row++) {
113
- for (let col = 0; col < colSize; col++) {
114
- if (cellIndex >= cellIds.length) break;
115
-
116
- const cellId = cellIds[cellIndex];
117
- tableRelatedIds.add(cellId);
118
-
119
- // Find the TableCell block
120
- const cellBlock = blockMap.get(cellId);
121
- if (cellBlock?.block_type === TABLE_CELL_BLOCK_TYPE) {
122
- // Get text content from cell's children (Text blocks)
123
- const childIds: string[] = cellBlock.children ?? [];
124
- const textParts: string[] = [];
125
-
126
- for (const childId of childIds) {
127
- tableRelatedIds.add(childId);
128
- const textBlock = blockMap.get(childId);
129
- if (textBlock?.block_type === 2 && textBlock.text?.elements) {
130
- const text = textBlock.text.elements
131
- .filter((e: any) => e.text_run)
132
- .map((e: any) => e.text_run.content)
133
- .join("");
134
- textParts.push(text);
135
- }
136
- }
137
-
138
- cells[row][col] = textParts.join("\n");
139
- }
140
-
141
- cellIndex++;
142
- }
143
- }
144
-
145
- tables.push({ rowSize, colSize, cells });
203
+ const { tableData, relatedIds } = extractTableData(block, blockMap);
204
+ tableDataMap.set(block.block_id, tableData);
205
+ for (const id of relatedIds) {
206
+ tableRelatedIds.add(id);
146
207
  }
147
208
  }
148
209
  }
149
210
 
150
- // Collect non-table blocks
211
+ // Second pass: build content items in order
212
+ const items: ContentItem[] = [];
151
213
  for (const block of blocks) {
152
- if (!tableRelatedIds.has(block.block_id)) {
153
- otherBlocks.push(block);
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);
154
242
  }
155
243
  }
156
244
 
@@ -183,10 +271,14 @@ async function convertMarkdown(client: Lark.Client, markdown: string) {
183
271
  data: { content_type: "markdown", content: markdown },
184
272
  });
185
273
  if (res.code !== 0) throw new Error(res.msg);
186
- return {
187
- blocks: res.data?.blocks ?? [],
188
- firstLevelBlockIds: res.data?.first_level_block_ids ?? [],
189
- };
274
+
275
+ const rawBlocks = res.data?.blocks ?? [];
276
+ const firstLevelBlockIds = res.data?.first_level_block_ids ?? [];
277
+
278
+ // Sort blocks according to first_level_block_ids (Convert API returns blocks in arbitrary order)
279
+ const blocks = sortBlocksByFirstLevelIds(rawBlocks, firstLevelBlockIds);
280
+
281
+ return { blocks, firstLevelBlockIds };
190
282
  }
191
283
 
192
284
  /** Insert blocks as children of a parent block (with batching for >50 blocks) */
@@ -231,6 +323,40 @@ async function insertBlocks(
231
323
  return { children: allChildren, skipped };
232
324
  }
233
325
 
326
+ /** Insert blocks at a specific index (with batching for >50 blocks) */
327
+ async function insertBlocksAtIndex(
328
+ client: Lark.Client,
329
+ docToken: string,
330
+ blocks: any[],
331
+ startIndex: number,
332
+ parentBlockId?: string,
333
+ ): Promise<{ children: any[]; skipped: string[] }> {
334
+ const { cleaned, skipped } = cleanBlocksForInsert(blocks);
335
+ const blockId = parentBlockId ?? docToken;
336
+
337
+ if (cleaned.length === 0) {
338
+ return { children: [], skipped };
339
+ }
340
+
341
+ const BATCH_SIZE = 50;
342
+ const allChildren: any[] = [];
343
+ let insertIndex = startIndex;
344
+
345
+ for (let i = 0; i < cleaned.length; i += BATCH_SIZE) {
346
+ const batch = cleaned.slice(i, i + BATCH_SIZE);
347
+ const res = await client.docx.documentBlockChildren.create({
348
+ path: { document_id: docToken, block_id: blockId },
349
+ data: { children: batch, index: insertIndex },
350
+ });
351
+ if (res.code !== 0) throw new Error(res.msg);
352
+ const inserted = res.data?.children ?? [];
353
+ allChildren.push(...inserted);
354
+ insertIndex += inserted.length;
355
+ }
356
+
357
+ return { children: allChildren, skipped };
358
+ }
359
+
234
360
  /** Delete all child blocks from a parent */
235
361
  async function clearDocumentContent(client: Lark.Client, docToken: string) {
236
362
  const existing = await client.docx.documentBlock.list({
@@ -294,6 +420,7 @@ async function createEmptyTable(
294
420
  parentBlockId: string,
295
421
  rowSize: number,
296
422
  colSize: number,
423
+ index?: number,
297
424
  ): Promise<string | null> {
298
425
  const tableBlock = {
299
426
  block_type: TABLE_BLOCK_TYPE,
@@ -309,7 +436,7 @@ async function createEmptyTable(
309
436
 
310
437
  const res = await client.docx.documentBlockChildren.create({
311
438
  path: { document_id: docToken, block_id: parentBlockId },
312
- data: { children: [tableBlock] },
439
+ data: { children: [tableBlock], ...(index !== undefined && { index }) },
313
440
  });
314
441
 
315
442
  if (res.code !== 0) {
@@ -421,6 +548,34 @@ async function createAndFillTable(
421
548
  return true;
422
549
  }
423
550
 
551
+ /** Create and fill a table at a specific index */
552
+ async function createAndFillTableAtIndex(
553
+ client: Lark.Client,
554
+ docToken: string,
555
+ parentBlockId: string,
556
+ tableData: TableData,
557
+ index: number,
558
+ ): Promise<boolean> {
559
+ // 1. Create empty table at specific index
560
+ const tableBlockId = await createEmptyTable(
561
+ client,
562
+ docToken,
563
+ parentBlockId,
564
+ tableData.rowSize,
565
+ tableData.colSize,
566
+ index,
567
+ );
568
+
569
+ if (!tableBlockId) {
570
+ return false;
571
+ }
572
+
573
+ // 2. Fill table content
574
+ await fillTableContent(client, docToken, tableBlockId, tableData.cells);
575
+
576
+ return true;
577
+ }
578
+
424
579
  /** Process images in markdown: download from URL, upload to Feishu, update blocks */
425
580
  async function processImages(
426
581
  client: Lark.Client,
@@ -661,30 +816,51 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
661
816
  return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0, tables_created: 0 };
662
817
  }
663
818
 
664
- // 3. Separate tables from other blocks
665
- const { tables, otherBlocks } = extractTableFromBlocks(blocks);
819
+ // 3. Convert to content items (preserves table positions)
820
+ const contentItems = convertBlocksToContentItems(blocks);
666
821
 
667
- // 4. Insert non-table blocks
668
- const { children: inserted, skipped } = await insertBlocks(client, docToken, otherBlocks);
669
-
670
- // 5. Create and fill tables
822
+ // 4. Insert content items in order
823
+ let insertIndex = 0;
824
+ const allInserted: any[] = [];
825
+ const allSkipped: string[] = [];
671
826
  let tablesCreated = 0;
672
- for (const tableData of tables) {
673
- const success = await createAndFillTable(client, docToken, docToken, tableData);
674
- if (success) tablesCreated++;
827
+ let pendingBlocks: any[] = [];
828
+
829
+ const flushPendingBlocks = async () => {
830
+ if (pendingBlocks.length === 0) return;
831
+ const { children, skipped } = await insertBlocksAtIndex(client, docToken, pendingBlocks, insertIndex);
832
+ allInserted.push(...children);
833
+ allSkipped.push(...skipped);
834
+ insertIndex += children.length;
835
+ pendingBlocks = [];
836
+ };
837
+
838
+ for (const item of contentItems) {
839
+ if (item.type === "block") {
840
+ pendingBlocks.push(item.block);
841
+ } else if (item.type === "table") {
842
+ await flushPendingBlocks();
843
+ const success = await createAndFillTableAtIndex(client, docToken, docToken, item.table, insertIndex);
844
+ if (success) {
845
+ tablesCreated++;
846
+ insertIndex++;
847
+ }
848
+ }
675
849
  }
676
850
 
677
- // 6. Process images
678
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
851
+ await flushPendingBlocks();
852
+
853
+ // 5. Process images
854
+ const imagesProcessed = await processImages(client, docToken, markdown, allInserted);
679
855
 
680
856
  return {
681
857
  success: true,
682
858
  blocks_deleted: deleted,
683
- blocks_added: inserted.length,
859
+ blocks_added: allInserted.length,
684
860
  tables_created: tablesCreated,
685
861
  images_processed: imagesProcessed,
686
- ...(skipped.length > 0 && {
687
- warning: `Skipped unsupported block types: ${skipped.join(", ")}.`,
862
+ ...(allSkipped.length > 0 && {
863
+ warning: `Skipped unsupported block types: ${allSkipped.join(", ")}.`,
688
864
  }),
689
865
  };
690
866
  }
@@ -696,30 +872,62 @@ async function appendDoc(client: Lark.Client, docToken: string, markdown: string
696
872
  throw new Error("Content is empty");
697
873
  }
698
874
 
699
- // 2. Separate tables from other blocks
700
- const { tables, otherBlocks } = extractTableFromBlocks(blocks);
875
+ // 2. Convert to content items (preserves table positions)
876
+ const contentItems = convertBlocksToContentItems(blocks);
701
877
 
702
- // 3. Insert non-table blocks
703
- const { children: inserted, skipped } = await insertBlocks(client, docToken, otherBlocks);
878
+ // 3. Get current insert index
879
+ let insertIndex = 0;
880
+ const existing = await client.docx.documentBlockChildren.get({
881
+ path: { document_id: docToken, block_id: docToken },
882
+ });
883
+ if (existing.code === 0) {
884
+ insertIndex = existing.data?.items?.length ?? 0;
885
+ }
704
886
 
705
- // 4. Create and fill tables
887
+ // 4. Insert content items in order, batching consecutive blocks
888
+ const allInserted: any[] = [];
889
+ const allSkipped: string[] = [];
706
890
  let tablesCreated = 0;
707
- for (const tableData of tables) {
708
- const success = await createAndFillTable(client, docToken, docToken, tableData);
709
- if (success) tablesCreated++;
891
+ let pendingBlocks: any[] = [];
892
+
893
+ const flushPendingBlocks = async () => {
894
+ if (pendingBlocks.length === 0) return;
895
+ const { children, skipped } = await insertBlocksAtIndex(client, docToken, pendingBlocks, insertIndex);
896
+ allInserted.push(...children);
897
+ allSkipped.push(...skipped);
898
+ insertIndex += children.length;
899
+ pendingBlocks = [];
900
+ };
901
+
902
+ for (const item of contentItems) {
903
+ if (item.type === "block") {
904
+ pendingBlocks.push(item.block);
905
+ } else if (item.type === "table") {
906
+ // Flush pending blocks before creating table
907
+ await flushPendingBlocks();
908
+ // Create table at current position
909
+ const success = await createAndFillTableAtIndex(client, docToken, docToken, item.table, insertIndex);
910
+ if (success) {
911
+ tablesCreated++;
912
+ insertIndex++;
913
+ }
914
+ }
710
915
  }
711
916
 
917
+ // Flush any remaining blocks
918
+ await flushPendingBlocks();
919
+
712
920
  // 5. Process images
713
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
921
+ const imagesProcessed = await processImages(client, docToken, markdown, allInserted);
714
922
 
715
923
  return {
716
924
  success: true,
717
- blocks_added: inserted.length,
925
+ blocks_added: allInserted.length,
718
926
  tables_created: tablesCreated,
719
927
  images_processed: imagesProcessed,
720
- block_ids: inserted.map((b: any) => b.block_id),
721
- ...(skipped.length > 0 && {
722
- warning: `Skipped unsupported block types: ${skipped.join(", ")}.`,
928
+ block_ids: allInserted.map((b: any) => b.block_id),
929
+ ...(allSkipped.length > 0 && {
930
+ warning: `Skipped unsupported block types: ${allSkipped.join(", ")}.`,
723
931
  }),
724
932
  };
725
933
  }