@max1874/feishu 0.2.11 → 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 +237 -87
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max1874/feishu",
3
- "version": "0.2.11",
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
@@ -129,12 +129,63 @@ interface TableData {
129
129
  cells: string[][]; // 2D array of cell text content
130
130
  }
131
131
 
132
- /** Extract table data from converted blocks (flat list with ID references) */
133
- function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBlocks: any[] } {
134
- const tables: TableData[] = [];
135
- 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
+ );
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
+ }
136
183
 
137
- // Build a map of block_id -> block for quick lookup
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[] {
138
189
  const blockMap = new Map<string, any>();
139
190
  for (const block of blocks) {
140
191
  if (block.block_id) {
@@ -142,69 +193,52 @@ function extractTableFromBlocks(blocks: any[]): { tables: TableData[]; otherBloc
142
193
  }
143
194
  }
144
195
 
145
- // Track which blocks are part of tables (to exclude from otherBlocks)
196
+ // Track which blocks are part of tables
146
197
  const tableRelatedIds = new Set<string>();
198
+ const tableDataMap = new Map<string, TableData>();
147
199
 
200
+ // First pass: extract all table data and mark related IDs
148
201
  for (const block of blocks) {
149
202
  if (block.block_type === TABLE_BLOCK_TYPE) {
150
- tableRelatedIds.add(block.block_id);
151
-
152
- const tableInfo = block.table;
153
- if (tableInfo) {
154
- const rowSize = tableInfo.property?.row_size ?? 0;
155
- const colSize = tableInfo.property?.column_size ?? 0;
156
-
157
- // Initialize cells array
158
- const cells: string[][] = Array.from({ length: rowSize }, () =>
159
- Array.from({ length: colSize }, () => ""),
160
- );
161
-
162
- // table.cells is a flat array of cell block IDs in row-major order
163
- const cellIds: string[] = tableInfo.cells ?? [];
164
-
165
- let cellIndex = 0;
166
- for (let row = 0; row < rowSize; row++) {
167
- for (let col = 0; col < colSize; col++) {
168
- if (cellIndex >= cellIds.length) break;
169
-
170
- const cellId = cellIds[cellIndex];
171
- tableRelatedIds.add(cellId);
172
-
173
- // Find the TableCell block
174
- const cellBlock = blockMap.get(cellId);
175
- if (cellBlock?.block_type === TABLE_CELL_BLOCK_TYPE) {
176
- // Get text content from cell's children (Text blocks)
177
- const childIds: string[] = cellBlock.children ?? [];
178
- const textParts: string[] = [];
179
-
180
- for (const childId of childIds) {
181
- tableRelatedIds.add(childId);
182
- const textBlock = blockMap.get(childId);
183
- if (textBlock?.block_type === 2 && textBlock.text?.elements) {
184
- const text = textBlock.text.elements
185
- .filter((e: any) => e.text_run)
186
- .map((e: any) => e.text_run.content)
187
- .join("");
188
- textParts.push(text);
189
- }
190
- }
191
-
192
- cells[row][col] = textParts.join("\n");
193
- }
194
-
195
- cellIndex++;
196
- }
197
- }
198
-
199
- 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);
200
207
  }
201
208
  }
202
209
  }
203
210
 
204
- // Collect non-table blocks
211
+ // Second pass: build content items in order
212
+ const items: ContentItem[] = [];
205
213
  for (const block of blocks) {
206
- if (!tableRelatedIds.has(block.block_id)) {
207
- 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);
208
242
  }
209
243
  }
210
244
 
@@ -289,6 +323,40 @@ async function insertBlocks(
289
323
  return { children: allChildren, skipped };
290
324
  }
291
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
+
292
360
  /** Delete all child blocks from a parent */
293
361
  async function clearDocumentContent(client: Lark.Client, docToken: string) {
294
362
  const existing = await client.docx.documentBlock.list({
@@ -352,6 +420,7 @@ async function createEmptyTable(
352
420
  parentBlockId: string,
353
421
  rowSize: number,
354
422
  colSize: number,
423
+ index?: number,
355
424
  ): Promise<string | null> {
356
425
  const tableBlock = {
357
426
  block_type: TABLE_BLOCK_TYPE,
@@ -367,7 +436,7 @@ async function createEmptyTable(
367
436
 
368
437
  const res = await client.docx.documentBlockChildren.create({
369
438
  path: { document_id: docToken, block_id: parentBlockId },
370
- data: { children: [tableBlock] },
439
+ data: { children: [tableBlock], ...(index !== undefined && { index }) },
371
440
  });
372
441
 
373
442
  if (res.code !== 0) {
@@ -479,6 +548,34 @@ async function createAndFillTable(
479
548
  return true;
480
549
  }
481
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
+
482
579
  /** Process images in markdown: download from URL, upload to Feishu, update blocks */
483
580
  async function processImages(
484
581
  client: Lark.Client,
@@ -719,30 +816,51 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
719
816
  return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0, tables_created: 0 };
720
817
  }
721
818
 
722
- // 3. Separate tables from other blocks
723
- const { tables, otherBlocks } = extractTableFromBlocks(blocks);
819
+ // 3. Convert to content items (preserves table positions)
820
+ const contentItems = convertBlocksToContentItems(blocks);
724
821
 
725
- // 4. Insert non-table blocks
726
- const { children: inserted, skipped } = await insertBlocks(client, docToken, otherBlocks);
727
-
728
- // 5. Create and fill tables
822
+ // 4. Insert content items in order
823
+ let insertIndex = 0;
824
+ const allInserted: any[] = [];
825
+ const allSkipped: string[] = [];
729
826
  let tablesCreated = 0;
730
- for (const tableData of tables) {
731
- const success = await createAndFillTable(client, docToken, docToken, tableData);
732
- 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
+ }
733
849
  }
734
850
 
735
- // 6. Process images
736
- 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);
737
855
 
738
856
  return {
739
857
  success: true,
740
858
  blocks_deleted: deleted,
741
- blocks_added: inserted.length,
859
+ blocks_added: allInserted.length,
742
860
  tables_created: tablesCreated,
743
861
  images_processed: imagesProcessed,
744
- ...(skipped.length > 0 && {
745
- warning: `Skipped unsupported block types: ${skipped.join(", ")}.`,
862
+ ...(allSkipped.length > 0 && {
863
+ warning: `Skipped unsupported block types: ${allSkipped.join(", ")}.`,
746
864
  }),
747
865
  };
748
866
  }
@@ -754,30 +872,62 @@ async function appendDoc(client: Lark.Client, docToken: string, markdown: string
754
872
  throw new Error("Content is empty");
755
873
  }
756
874
 
757
- // 2. Separate tables from other blocks
758
- const { tables, otherBlocks } = extractTableFromBlocks(blocks);
875
+ // 2. Convert to content items (preserves table positions)
876
+ const contentItems = convertBlocksToContentItems(blocks);
759
877
 
760
- // 3. Insert non-table blocks
761
- 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
+ }
762
886
 
763
- // 4. Create and fill tables
887
+ // 4. Insert content items in order, batching consecutive blocks
888
+ const allInserted: any[] = [];
889
+ const allSkipped: string[] = [];
764
890
  let tablesCreated = 0;
765
- for (const tableData of tables) {
766
- const success = await createAndFillTable(client, docToken, docToken, tableData);
767
- 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
+ }
768
915
  }
769
916
 
917
+ // Flush any remaining blocks
918
+ await flushPendingBlocks();
919
+
770
920
  // 5. Process images
771
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
921
+ const imagesProcessed = await processImages(client, docToken, markdown, allInserted);
772
922
 
773
923
  return {
774
924
  success: true,
775
- blocks_added: inserted.length,
925
+ blocks_added: allInserted.length,
776
926
  tables_created: tablesCreated,
777
927
  images_processed: imagesProcessed,
778
- block_ids: inserted.map((b: any) => b.block_id),
779
- ...(skipped.length > 0 && {
780
- 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(", ")}.`,
781
931
  }),
782
932
  };
783
933
  }