mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 06:20:55 +00:00
* feat(feishu): add markdown tables, insert, color_text, table ops, and image fixes Extends feishu_doc on top of #20304 with capabilities that are not yet covered: Markdown → native table rendering: - write/append now use the Descendant API instead of Children API, enabling GFM markdown tables (block_type 31/32) to render as native Feishu tables automatically - Adaptive column widths calculated from cell content (CJK chars 2x weight) - Batch insertion for large documents (>1000 blocks, docx-batch-insert.ts) New actions: - insert: positional markdown insertion after a given block_id - color_text: apply color/bold to a text block via [red]...[/red] markup - insert_table_row / insert_table_column: add rows or columns to a table - delete_table_rows / delete_table_columns: remove rows or columns - merge_table_cells: merge a rectangular cell range Image upload fixes (affects write, append, and upload_image): - upload_image now accepts data URI and plain base64 in addition to url/file_path, covering DALL-E b64_json, canvas screenshots, etc. - Fix: pass Buffer directly to drive.media.uploadAll instead of Readable.from(), which caused Content-Length mismatch for large images - Fix: same Readable bug fixed in upload_file - Fix: pass drive_route_token via extra field for correct multi-datacenter routing (per API docs: required when parent_node is a document block ID) * fix(feishu): add documentBlockDescendant mock to docx.test.ts write/append now use the Descendant API (documentBlockDescendant.create) instead of Children API. The existing test mock was missing this SDK method, causing processImages to never be reached and fetchRemoteMedia to go uncalled. Added blockDescendantCreateMock returning an image block so the 'skips image upload when markdown image URL is blocked' test flows through processImages as expected. * fix(feishu): address bot review feedback - resolveUploadInput: remove length < 1024 guard on file path detection. Prefix patterns (isAbsolute / ~ / ./ / ../) already correctly distinguish file paths from base64 strings at any length. The old guard caused file paths ≥1024 chars to fall through to the base64 branch incorrectly. - parseColorMarkup: add comment clarifying that mismatched closing tags (e.g. [red]text[/green]) are intentional — opening tag style is applied, closing tag is consumed regardless of name. * fix(feishu): address second-round codex bot review feedback P1 - Reject single oversized subtrees in batch insert (docx-batch-insert.ts): A first-level block whose descendant count exceeds BATCH_SIZE (1000) cannot be split atomically (e.g. a very large table). Previously such a block was silently added to the current batch and sent as an oversized request, violating the API limit. Now throws a descriptive error so callers know to reduce the content size. P2 - Preserve unmatched brackets in color markup parser (docx-color-text.ts): Text like 'Revenue [Q1] up' contains a bracket pair with no matching '[/...]' closer. The original regex dropped the '[' character in this case, silently corrupting the text. Fixed by appending '|\[' to the plain-text alternative so any '[' that does not open a complete tag is captured as literal text. * fix(feishu): address third-round codex bot review feedback P2 - Throw ENOENT for non-existing absolute image paths (docx.ts): Previously a non-existing absolute path like /tmp/missing.png fell through to Buffer.from(..., 'base64') and uploaded garbage bytes. Now throws a descriptive ENOENT error and hints at data URI format for callers intending to pass JPEG binary data (which starts with /9j/). P2 - Fail clearly when insert anchor block is not found (docx.ts): insertDoc previously set insertIndex to -1 (append) when after_block_id was absent from the parent's child list, silently inserting at the wrong position. Two fixes: 1. Paginate through all children (documentBlockChildren.get returns up to 200 per page) before searching for the anchor. 2. Throw a descriptive error if after_block_id is still not found after full pagination, instead of silently falling back to append. * fix(feishu): address fourth-round codex bot review feedback - Enforce mutual exclusivity across all three upload sources (url, file_path, image): throw immediately when more than one is provided, instead of silently preferring the image branch and ignoring the others. - Validate plain base64 payloads before decoding: reject strings that contain characters outside the standard base64 alphabet ([A-Za-z0-9+/=]) so that malformed inputs fail fast with a clear error rather than decoding to garbage bytes and producing an opaque Feishu API failure downstream. Also throw if the decoded buffer is empty. * fix(feishu): address fifth-round codex bot review feedback - parseColorMarkup: restrict opening tag regex to known colour/style names (bg:*, bold, red, orange, yellow, green, blue, purple, grey/gray) so that ordinary bracket tokens like [Q1] can no longer consume a subsequent real closing tag ([/red]) and corrupt the surrounding styled spans. Unknown tags now fall through to the plain-text alternatives and are emitted literally. - resolveUploadInput: estimate decoded byte count from base64 input length (ceil(len * 3 / 4)) BEFORE allocating the full Buffer, preventing oversized payloads from spiking memory before the maxBytes limit is enforced. Applies to both the data-URI branch and the plain-base64 branch. * fix(feishu): address sixth-round codex bot review feedback - docx-table-ops: apply MIN/MAX_COLUMN_WIDTH clamping in the empty-table branch so tables with 15+ columns don't produce sub-50 widths that Feishu rejects as invalid column_width values. - docx.ts (data URI branch): validate the ';base64' marker before decoding so plain/URL-encoded data URIs are rejected with a clear error; also validate the payload against the base64 alphabet (same guard already applied in the plain-base64 branch) so malformed inputs fail fast rather than producing opaque downstream Feishu errors. * Feishu: align docx descendant insertion tests and changelog --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
299 lines
9.2 KiB
TypeScript
299 lines
9.2 KiB
TypeScript
/**
|
|
* Table utilities and row/column manipulation operations for Feishu documents.
|
|
*
|
|
* Combines:
|
|
* - Adaptive column width calculation (content-proportional, CJK-aware)
|
|
* - Block cleaning for Descendant API (removes read-only fields)
|
|
* - Table row/column insert, delete, and merge operations
|
|
*/
|
|
|
|
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
|
|
// ============ Table Utilities ============
|
|
|
|
// Feishu table constraints
|
|
const MIN_COLUMN_WIDTH = 50; // Feishu API minimum
|
|
const MAX_COLUMN_WIDTH = 400; // Reasonable maximum for readability
|
|
const DEFAULT_TABLE_WIDTH = 730; // Approximate Feishu page content width
|
|
|
|
/**
|
|
* Calculate adaptive column widths based on cell content length.
|
|
*
|
|
* Algorithm:
|
|
* 1. For each column, find the max content length across all rows
|
|
* 2. Weight CJK characters as 2x width (they render wider)
|
|
* 3. Calculate proportional widths based on content length
|
|
* 4. Apply min/max constraints
|
|
* 5. Redistribute remaining space to fill total table width
|
|
*
|
|
* Total width is derived from the original column_width values returned
|
|
* by the Convert API, ensuring tables match Feishu's expected dimensions.
|
|
*
|
|
* @param blocks - Array of blocks from Convert API
|
|
* @param tableBlockId - The block_id of the table block
|
|
* @returns Array of column widths in pixels
|
|
*/
|
|
export function calculateAdaptiveColumnWidths(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
blocks: any[],
|
|
tableBlockId: string,
|
|
): number[] {
|
|
// Find the table block
|
|
const tableBlock = blocks.find((b) => b.block_id === tableBlockId && b.block_type === 31);
|
|
|
|
if (!tableBlock?.table?.property) {
|
|
return [];
|
|
}
|
|
|
|
const { row_size, column_size, column_width: originalWidths } = tableBlock.table.property;
|
|
|
|
// Use original total width from Convert API, or fall back to default
|
|
const totalWidth =
|
|
originalWidths && originalWidths.length > 0
|
|
? originalWidths.reduce((a: number, b: number) => a + b, 0)
|
|
: DEFAULT_TABLE_WIDTH;
|
|
const cellIds: string[] = tableBlock.children || [];
|
|
|
|
// Build block lookup map
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const blockMap = new Map<string, any>();
|
|
for (const block of blocks) {
|
|
blockMap.set(block.block_id, block);
|
|
}
|
|
|
|
// Extract text content from a table cell
|
|
function getCellText(cellId: string): string {
|
|
const cell = blockMap.get(cellId);
|
|
if (!cell?.children) return "";
|
|
|
|
let text = "";
|
|
const childIds = Array.isArray(cell.children) ? cell.children : [cell.children];
|
|
|
|
for (const childId of childIds) {
|
|
const child = blockMap.get(childId);
|
|
if (child?.text?.elements) {
|
|
for (const elem of child.text.elements) {
|
|
if (elem.text_run?.content) {
|
|
text += elem.text_run.content;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// Calculate weighted length (CJK chars count as 2)
|
|
// CJK (Chinese/Japanese/Korean) characters render ~2x wider than ASCII
|
|
function getWeightedLength(text: string): number {
|
|
return [...text].reduce((sum, char) => {
|
|
return sum + (char.charCodeAt(0) > 255 ? 2 : 1);
|
|
}, 0);
|
|
}
|
|
|
|
// Find max content length per column
|
|
const maxLengths: number[] = new Array(column_size).fill(0);
|
|
|
|
for (let row = 0; row < row_size; row++) {
|
|
for (let col = 0; col < column_size; col++) {
|
|
const cellIndex = row * column_size + col;
|
|
const cellId = cellIds[cellIndex];
|
|
if (cellId) {
|
|
const content = getCellText(cellId);
|
|
const length = getWeightedLength(content);
|
|
maxLengths[col] = Math.max(maxLengths[col], length);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle empty table: distribute width equally, clamped to [MIN, MAX] so
|
|
// wide tables (e.g. 15+ columns) don't produce sub-50 widths that Feishu
|
|
// rejects as invalid column_width values.
|
|
const totalLength = maxLengths.reduce((a, b) => a + b, 0);
|
|
if (totalLength === 0) {
|
|
const equalWidth = Math.max(
|
|
MIN_COLUMN_WIDTH,
|
|
Math.min(MAX_COLUMN_WIDTH, Math.floor(totalWidth / column_size)),
|
|
);
|
|
return new Array(column_size).fill(equalWidth);
|
|
}
|
|
|
|
// Calculate proportional widths
|
|
let widths = maxLengths.map((len) => {
|
|
const proportion = len / totalLength;
|
|
return Math.round(proportion * totalWidth);
|
|
});
|
|
|
|
// Apply min/max constraints
|
|
widths = widths.map((w) => Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, w)));
|
|
|
|
// Redistribute remaining space to fill total width
|
|
let remaining = totalWidth - widths.reduce((a, b) => a + b, 0);
|
|
while (remaining > 0) {
|
|
// Find columns that can still grow (not at max)
|
|
const growable = widths.map((w, i) => (w < MAX_COLUMN_WIDTH ? i : -1)).filter((i) => i >= 0);
|
|
if (growable.length === 0) break;
|
|
|
|
// Distribute evenly among growable columns
|
|
const perColumn = Math.floor(remaining / growable.length);
|
|
if (perColumn === 0) break;
|
|
|
|
for (const i of growable) {
|
|
const add = Math.min(perColumn, MAX_COLUMN_WIDTH - widths[i]);
|
|
widths[i] += add;
|
|
remaining -= add;
|
|
}
|
|
}
|
|
|
|
return widths;
|
|
}
|
|
|
|
/**
|
|
* Clean blocks for Descendant API with adaptive column widths.
|
|
*
|
|
* - Removes parent_id from all blocks
|
|
* - Fixes children type (string → array) for TableCell blocks
|
|
* - Removes merge_info (read-only, causes API error)
|
|
* - Calculates and applies adaptive column_width for tables
|
|
*
|
|
* @param blocks - Array of blocks from Convert API
|
|
* @returns Cleaned blocks ready for Descendant API
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function cleanBlocksForDescendant(blocks: any[]): any[] {
|
|
// Pre-calculate adaptive widths for all tables
|
|
const tableWidths = new Map<string, number[]>();
|
|
for (const block of blocks) {
|
|
if (block.block_type === 31) {
|
|
const widths = calculateAdaptiveColumnWidths(blocks, block.block_id);
|
|
tableWidths.set(block.block_id, widths);
|
|
}
|
|
}
|
|
|
|
return blocks.map((block) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { parent_id: _parentId, ...cleanBlock } = block;
|
|
|
|
// Fix: Convert API sometimes returns children as string for TableCell
|
|
if (cleanBlock.block_type === 32 && typeof cleanBlock.children === "string") {
|
|
cleanBlock.children = [cleanBlock.children];
|
|
}
|
|
|
|
// Clean table blocks
|
|
if (cleanBlock.block_type === 31 && cleanBlock.table) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { cells: _cells, ...tableWithoutCells } = cleanBlock.table;
|
|
const { row_size, column_size } = tableWithoutCells.property || {};
|
|
const adaptiveWidths = tableWidths.get(block.block_id);
|
|
|
|
cleanBlock.table = {
|
|
property: {
|
|
row_size,
|
|
column_size,
|
|
...(adaptiveWidths?.length && { column_width: adaptiveWidths }),
|
|
},
|
|
};
|
|
}
|
|
|
|
return cleanBlock;
|
|
});
|
|
}
|
|
|
|
// ============ Table Row/Column Operations ============
|
|
|
|
export async function insertTableRow(
|
|
client: Lark.Client,
|
|
docToken: string,
|
|
blockId: string,
|
|
rowIndex: number = -1,
|
|
) {
|
|
const res = await client.docx.documentBlock.patch({
|
|
path: { document_id: docToken, block_id: blockId },
|
|
data: { insert_table_row: { row_index: rowIndex } },
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
return { success: true, block: res.data?.block };
|
|
}
|
|
|
|
export async function insertTableColumn(
|
|
client: Lark.Client,
|
|
docToken: string,
|
|
blockId: string,
|
|
columnIndex: number = -1,
|
|
) {
|
|
const res = await client.docx.documentBlock.patch({
|
|
path: { document_id: docToken, block_id: blockId },
|
|
data: { insert_table_column: { column_index: columnIndex } },
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
return { success: true, block: res.data?.block };
|
|
}
|
|
|
|
export async function deleteTableRows(
|
|
client: Lark.Client,
|
|
docToken: string,
|
|
blockId: string,
|
|
rowStart: number,
|
|
rowCount: number = 1,
|
|
) {
|
|
const res = await client.docx.documentBlock.patch({
|
|
path: { document_id: docToken, block_id: blockId },
|
|
data: { delete_table_rows: { row_start_index: rowStart, row_end_index: rowStart + rowCount } },
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
return { success: true, rows_deleted: rowCount, block: res.data?.block };
|
|
}
|
|
|
|
export async function deleteTableColumns(
|
|
client: Lark.Client,
|
|
docToken: string,
|
|
blockId: string,
|
|
columnStart: number,
|
|
columnCount: number = 1,
|
|
) {
|
|
const res = await client.docx.documentBlock.patch({
|
|
path: { document_id: docToken, block_id: blockId },
|
|
data: {
|
|
delete_table_columns: {
|
|
column_start_index: columnStart,
|
|
column_end_index: columnStart + columnCount,
|
|
},
|
|
},
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
return { success: true, columns_deleted: columnCount, block: res.data?.block };
|
|
}
|
|
|
|
export async function mergeTableCells(
|
|
client: Lark.Client,
|
|
docToken: string,
|
|
blockId: string,
|
|
rowStart: number,
|
|
rowEnd: number,
|
|
columnStart: number,
|
|
columnEnd: number,
|
|
) {
|
|
const res = await client.docx.documentBlock.patch({
|
|
path: { document_id: docToken, block_id: blockId },
|
|
data: {
|
|
merge_table_cells: {
|
|
row_start_index: rowStart,
|
|
row_end_index: rowEnd,
|
|
column_start_index: columnStart,
|
|
column_end_index: columnEnd,
|
|
},
|
|
},
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
return { success: true, block: res.data?.block };
|
|
}
|