diff --git a/shortcuts/doc/docs_table_update.go b/shortcuts/doc/docs_table_update.go new file mode 100644 index 00000000..c3035c31 --- /dev/null +++ b/shortcuts/doc/docs_table_update.go @@ -0,0 +1,521 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/shortcuts/common" +) + +var validActions = map[string]bool{ + "update-cell": true, + "insert-row": true, + "delete-rows": true, + "insert-col": true, + "delete-cols": true, +} + +var DocsTableUpdate = common.Shortcut{ + Service: "docs", + Command: "+table-update", + Description: "Update a table inside a Lark document (cell edit, row/col insert/delete)", + Risk: "write", + Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "action", Desc: "operation type", Default: "update-cell", Enum: []string{"update-cell", "insert-row", "delete-rows", "insert-col", "delete-cols"}}, + {Name: "table-index", Desc: "table index in document (0-based)", Default: "0"}, + {Name: "table-id", Desc: "table block_id (overrides --table-index)"}, + {Name: "row", Desc: "row index (0-based, for update-cell)"}, + {Name: "col", Desc: "column index (0-based, for update-cell)"}, + {Name: "at", Desc: "insert position index (for insert-row/insert-col)"}, + {Name: "from", Desc: "start index inclusive (for delete-rows/delete-cols)"}, + {Name: "to", Desc: "end index exclusive (for delete-rows/delete-cols)"}, + {Name: "markdown", Desc: "new cell content (for update-cell)", Input: []string{common.File, common.Stdin}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + action := runtime.Str("action") + if !validActions[action] { + return common.FlagErrorf("invalid --action %q", action) + } + + switch action { + case "update-cell": + if runtime.Str("row") == "" || runtime.Str("col") == "" { + return common.FlagErrorf("--action update-cell requires --row and --col") + } + if _, err := parseNonNegativeInt(runtime.Str("row"), "row"); err != nil { + return err + } + if _, err := parseNonNegativeInt(runtime.Str("col"), "col"); err != nil { + return err + } + if runtime.Str("markdown") == "" { + return common.FlagErrorf("--action update-cell requires --markdown") + } + case "insert-row", "insert-col": + if runtime.Str("at") == "" { + return common.FlagErrorf("--action %s requires --at", action) + } + if _, err := parseNonNegativeInt(runtime.Str("at"), "at"); err != nil { + return err + } + case "delete-rows", "delete-cols": + if runtime.Str("from") == "" || runtime.Str("to") == "" { + return common.FlagErrorf("--action %s requires --from and --to", action) + } + from, err := parseNonNegativeInt(runtime.Str("from"), "from") + if err != nil { + return err + } + to, err := parseNonNegativeInt(runtime.Str("to"), "to") + if err != nil { + return err + } + if from >= to { + return common.FlagErrorf("--from (%d) must be less than --to (%d)", from, to) + } + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parseDocumentRef(runtime.Str("doc")) + if err != nil { + return err + } + + // Only docx documents support block-level table operations. + if ref.Kind == "doc" { + return output.ErrValidation("legacy /doc/ documents are not supported; only /docx/ (new-format) documents can use +table-update") + } + + docID := ref.Token + // For wiki URLs, resolve the actual document ID and verify it's a docx. + if ref.Kind == "wiki" { + resolved, objType, err := resolveWikiNode(runtime, ref.Token) + if err != nil { + return err + } + if objType != "docx" { + return output.ErrValidation("wiki node is type %q, but +table-update only supports docx documents", objType) + } + docID = resolved + } + + action := runtime.Str("action") + + // Find the target table block ID. + tableBlockID := runtime.Str("table-id") + if tableBlockID == "" { + idx, err := strconv.Atoi(runtime.Str("table-index")) + if err != nil || idx < 0 { + return common.FlagErrorf("--table-index must be a non-negative integer") + } + tableBlockID, err = findTableBlockID(runtime, docID, idx) + if err != nil { + return err + } + } + + switch action { + case "update-cell": + return execUpdateCell(runtime, docID, tableBlockID) + case "insert-row": + return execInsertRow(runtime, docID, tableBlockID) + case "delete-rows": + return execDeleteRows(runtime, docID, tableBlockID) + case "insert-col": + return execInsertCol(runtime, docID, tableBlockID) + case "delete-cols": + return execDeleteCols(runtime, docID, tableBlockID) + default: + return common.FlagErrorf("unknown action %q", action) + } + }, +} + +// resolveWikiNode calls the wiki API to get the real document token and type. +func resolveWikiNode(runtime *common.RuntimeContext, wikiToken string) (objToken string, objType string, err error) { + data, err := runtime.CallAPI(http.MethodGet, + fmt.Sprintf("/open-apis/wiki/v2/spaces/get_node?token=%s", wikiToken), + nil, nil) + if err != nil { + return "", "", fmt.Errorf("resolve wiki token: %w", err) + } + node := common.GetMap(data, "node") + objToken = common.GetString(node, "obj_token") + if objToken == "" { + return "", "", output.ErrValidation("wiki node has no obj_token") + } + objType = common.GetString(node, "obj_type") + return objToken, objType, nil +} + +// parseNonNegativeInt parses a string flag as a non-negative integer. +func parseNonNegativeInt(s, flagName string) (int, error) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, common.FlagErrorf("--%s must be a valid integer, got %q", flagName, s) + } + if n < 0 { + return 0, common.FlagErrorf("--%s must be non-negative, got %d", flagName, n) + } + return n, nil +} + +// findTableBlockID lists document blocks and returns the block_id of the N-th table. +func findTableBlockID(runtime *common.RuntimeContext, docID string, tableIndex int) (string, error) { + blocks, err := listAllBlocks(runtime, docID) + if err != nil { + return "", err + } + + tableCount := 0 + for _, b := range blocks { + bm, ok := b.(map[string]interface{}) + if !ok { + continue + } + blockType, _ := util.ToFloat64(bm["block_type"]) + // Block type 31 = table in Lark docx API. + if int(blockType) == 31 { + if tableCount == tableIndex { + blockID, _ := bm["block_id"].(string) + if blockID == "" { + return "", fmt.Errorf("table block has no block_id") + } + return blockID, nil + } + tableCount++ + } + } + return "", output.ErrValidation("document has only %d table(s), but --table-index is %d", tableCount, tableIndex) +} + +// listAllBlocks fetches all blocks of a document with pagination. +func listAllBlocks(runtime *common.RuntimeContext, docID string) ([]interface{}, error) { + var allBlocks []interface{} + pageToken := "" + + for { + url := fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks?page_size=500", docID) + if pageToken != "" { + url += "&page_token=" + pageToken + } + data, err := runtime.CallAPI(http.MethodGet, url, nil, nil) + if err != nil { + return nil, fmt.Errorf("list blocks: %w", err) + } + items := common.GetSlice(data, "items") + allBlocks = append(allBlocks, items...) + + hasMore := common.GetBool(data, "has_more") + if !hasMore { + break + } + pageToken = common.GetString(data, "page_token") + if pageToken == "" { + break + } + } + return allBlocks, nil +} + +// getTableInfo retrieves the table block to get row/col count and cell block IDs. +func getTableInfo(runtime *common.RuntimeContext, docID, tableBlockID string) (rows int, cols int, cells []string, err error) { + url := fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", docID, tableBlockID) + data, err := runtime.CallAPI(http.MethodGet, url, nil, nil) + if err != nil { + return 0, 0, nil, fmt.Errorf("get table block: %w", err) + } + block := common.GetMap(data, "block") + table := common.GetMap(block, "table") + if table == nil { + return 0, 0, nil, fmt.Errorf("block %s is not a table", tableBlockID) + } + + prop := common.GetMap(table, "property") + rowSize := int(common.GetFloat(prop, "row_size")) + colSize := int(common.GetFloat(prop, "column_size")) + + cellItems := common.GetSlice(table, "cells") + cellIDs := make([]string, 0, len(cellItems)) + for _, c := range cellItems { + if s, ok := c.(string); ok { + cellIDs = append(cellIDs, s) + } + } + + return rowSize, colSize, cellIDs, nil +} + +// execUpdateCell updates a single cell's content via MCP. +func execUpdateCell(runtime *common.RuntimeContext, docID, tableBlockID string) error { + row, _ := parseNonNegativeInt(runtime.Str("row"), "row") // validated in Validate + col, _ := parseNonNegativeInt(runtime.Str("col"), "col") // validated in Validate + markdown := runtime.Str("markdown") + + rows, cols, cells, err := getTableInfo(runtime, docID, tableBlockID) + if err != nil { + return err + } + if row < 0 || row >= rows { + return output.ErrValidation("--row %d out of range [0, %d)", row, rows) + } + if col < 0 || col >= cols { + return output.ErrValidation("--col %d out of range [0, %d)", col, cols) + } + + cellIndex := row*cols + col + if cellIndex >= len(cells) { + return output.ErrValidation("cell index %d out of range (table has %d cells)", cellIndex, len(cells)) + } + cellBlockID := cells[cellIndex] + + // Step 1: Delete all existing children of the cell. + if err := deleteCellChildren(runtime, docID, cellBlockID); err != nil { + return fmt.Errorf("clear cell: %w", err) + } + + // Step 2: Create new content in the cell. + // For simple text, directly create a text block. For complex markdown, use MCP. + if err := createCellContent(runtime, docID, cellBlockID, markdown); err != nil { + return fmt.Errorf("write cell content: %w", err) + } + + runtime.Out(map[string]interface{}{ + "success": true, + "action": "update-cell", + "doc_id": docID, + "table_id": tableBlockID, + "cell_id": cellBlockID, + "row": row, + "col": col, + "message": fmt.Sprintf("cell [%d,%d] updated successfully", row, col), + }, nil) + return nil +} + +// deleteCellChildren removes all child blocks from a cell. +func deleteCellChildren(runtime *common.RuntimeContext, docID, cellBlockID string) error { + // List children of the cell block. + url := fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", docID, cellBlockID) + data, err := runtime.CallAPI(http.MethodGet, url, nil, nil) + if err != nil { + return err + } + items := common.GetSlice(data, "items") + if len(items) == 0 { + return nil + } + + // Collect child block IDs. + childIDs := make([]string, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + if id := common.GetString(m, "block_id"); id != "" { + childIDs = append(childIDs, id) + } + } + } + if len(childIDs) == 0 { + return nil + } + + // Batch delete children. + deleteURL := fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", docID, cellBlockID) + body := map[string]interface{}{ + "start_index": 0, + "end_index": len(childIDs), + } + _, err = runtime.CallAPI(http.MethodDelete, deleteURL, nil, body) + return err +} + +// createCellContent creates a text block inside a cell. +func createCellContent(runtime *common.RuntimeContext, docID, cellBlockID, markdown string) error { + // Create a simple text block as child of the cell. + url := fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", docID, cellBlockID) + body := map[string]interface{}{ + "children": []map[string]interface{}{ + { + "block_type": 2, // text block + "text": map[string]interface{}{ + "elements": []map[string]interface{}{ + { + "text_run": map[string]interface{}{ + "content": markdown, + }, + }, + }, + "style": map[string]interface{}{}, + }, + }, + }, + } + + resp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: url, + Body: body, + }) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("create cell content: HTTP %d: %s", resp.StatusCode, string(resp.RawBody)) + } + + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(resp.RawBody, &envelope); err != nil { + return fmt.Errorf("parse response: %w", err) + } + if envelope.Code != 0 { + return output.ErrAPI(envelope.Code, fmt.Sprintf("create cell content: %s", envelope.Msg), nil) + } + return nil +} + +// execInsertRow inserts a row at the specified index. +func execInsertRow(runtime *common.RuntimeContext, docID, tableBlockID string) error { + at, _ := parseNonNegativeInt(runtime.Str("at"), "at") // validated in Validate + + body := map[string]interface{}{ + "insert_table_row": map[string]interface{}{ + "row_index": at, + }, + } + if err := patchBlock(runtime, docID, tableBlockID, body); err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "success": true, + "action": "insert-row", + "doc_id": docID, + "table_id": tableBlockID, + "at": at, + "message": fmt.Sprintf("row inserted at index %d", at), + }, nil) + return nil +} + +// execDeleteRows deletes rows in [from, to) range. +func execDeleteRows(runtime *common.RuntimeContext, docID, tableBlockID string) error { + from, _ := parseNonNegativeInt(runtime.Str("from"), "from") // validated in Validate + to, _ := parseNonNegativeInt(runtime.Str("to"), "to") // validated in Validate + + body := map[string]interface{}{ + "delete_table_rows": map[string]interface{}{ + "row_start_index": from, + "row_end_index": to, + }, + } + if err := patchBlock(runtime, docID, tableBlockID, body); err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "success": true, + "action": "delete-rows", + "doc_id": docID, + "table_id": tableBlockID, + "from": from, + "to": to, + "message": fmt.Sprintf("rows [%d, %d) deleted", from, to), + }, nil) + return nil +} + +// execInsertCol inserts a column at the specified index. +func execInsertCol(runtime *common.RuntimeContext, docID, tableBlockID string) error { + at, _ := parseNonNegativeInt(runtime.Str("at"), "at") // validated in Validate + + body := map[string]interface{}{ + "insert_table_column": map[string]interface{}{ + "column_index": at, + }, + } + if err := patchBlock(runtime, docID, tableBlockID, body); err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "success": true, + "action": "insert-col", + "doc_id": docID, + "table_id": tableBlockID, + "at": at, + "message": fmt.Sprintf("column inserted at index %d", at), + }, nil) + return nil +} + +// execDeleteCols deletes columns in [from, to) range. +func execDeleteCols(runtime *common.RuntimeContext, docID, tableBlockID string) error { + from, _ := parseNonNegativeInt(runtime.Str("from"), "from") // validated in Validate + to, _ := parseNonNegativeInt(runtime.Str("to"), "to") // validated in Validate + + body := map[string]interface{}{ + "delete_table_columns": map[string]interface{}{ + "column_start_index": from, + "column_end_index": to, + }, + } + if err := patchBlock(runtime, docID, tableBlockID, body); err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "success": true, + "action": "delete-cols", + "doc_id": docID, + "table_id": tableBlockID, + "from": from, + "to": to, + "message": fmt.Sprintf("columns [%d, %d) deleted", from, to), + }, nil) + return nil +} + +// patchBlock calls the docx block patch API. +func patchBlock(runtime *common.RuntimeContext, docID, blockID string, body map[string]interface{}) error { + apiPath := fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", docID, blockID) + resp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPatch, + ApiPath: apiPath, + Body: body, + }) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("patch block: HTTP %d: %s", resp.StatusCode, string(resp.RawBody)) + } + + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(resp.RawBody, &envelope); err != nil { + return fmt.Errorf("parse patch response: %w", err) + } + if envelope.Code != 0 { + return output.ErrAPI(envelope.Code, fmt.Sprintf("patch block: %s", envelope.Msg), nil) + } + return nil +} diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go index 6f3f3ab6..1e0dcf2c 100644 --- a/shortcuts/doc/shortcuts.go +++ b/shortcuts/doc/shortcuts.go @@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut { DocsCreate, DocsFetch, DocsUpdate, + DocsTableUpdate, DocMediaInsert, DocMediaPreview, DocMediaDownload, diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 571696cd..4ca0f5b7 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -184,6 +184,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`) | [`+create`](references/lark-doc-create.md) | Create a Lark document | | [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content | | [`+update`](references/lark-doc-update.md) | Update a Lark document | +| [`+table-update`](references/lark-doc-table-update.md) | Update a table inside a Lark document (cell edit, row/col insert/delete) | | [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback) | | [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) | | [`+whiteboard-update`](references/lark-doc-whiteboard-update.md) | Update an existing whiteboard in lark document with whiteboard dsl. Such DSL input from stdin. refer to lark-whiteboard skill for more details. | diff --git a/skills/lark-doc/references/lark-doc-table-update.md b/skills/lark-doc/references/lark-doc-table-update.md new file mode 100644 index 00000000..76f98557 --- /dev/null +++ b/skills/lark-doc/references/lark-doc-table-update.md @@ -0,0 +1,135 @@ + +# docs +table-update(飞书文档表格编辑) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +直接操作飞书文档中的表格,支持修改单元格内容、插入/删除行列。与 `docs +update` 不同,本命令通过 docx block API 精确操作表格内部结构,不依赖文本定位。 + +## 重要说明 + +> **⚠️ 本命令直接操作文档 block 结构,修改单元格时会先清空再写入,请确保操作正确。** +> +> **所有索引参数(`--row`、`--col`、`--at`、`--from`、`--to`、`--table-index`)均为 0-based。** + +## 命令 + +```bash +# 修改单元格内容(索引从 0 开始:第 1 个表格、第 3 行、第 2 列) +lark-cli docs +table-update --doc "" --table-index 0 --action update-cell --row 2 --col 1 --markdown "新内容" + +# 插入一行(在索引 3 的位置插入,即原第 4 行之前) +lark-cli docs +table-update --doc "" --table-index 0 --action insert-row --at 3 + +# 删除行(删除索引 [2, 3) 即第 3 行) +lark-cli docs +table-update --doc "" --table-index 0 --action delete-rows --from 2 --to 3 + +# 插入一列(在索引 2 的位置插入,即原第 3 列之前) +lark-cli docs +table-update --doc "" --table-index 0 --action insert-col --at 2 + +# 删除列(删除索引 [1, 2) 即第 2 列) +lark-cli docs +table-update --doc "" --table-index 0 --action delete-cols --from 1 --to 2 + +# 直接指定表格 block_id(跳过查找),修改第 1 行第 1 列(索引 0,0) +lark-cli docs +table-update --doc "" --table-id "blk_xxxxx" --action update-cell --row 0 --col 0 --markdown "标题" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--doc` | 是 | 文档 URL 或 token(支持 wiki/docx 链接) | +| `--action` | 否 | 操作类型,默认 `update-cell`。可选:`update-cell`、`insert-row`、`delete-rows`、`insert-col`、`delete-cols` | +| `--table-index` | 否 | 文档中第 N 个表格(0-based),默认 `0` | +| `--table-id` | 否 | 表格 block_id(覆盖 `--table-index`) | +| `--row` | 视 action | 行索引(0-based),`update-cell` 时必填 | +| `--col` | 视 action | 列索引(0-based),`update-cell` 时必填 | +| `--at` | 视 action | 插入位置索引,`insert-row`/`insert-col` 时必填 | +| `--from` | 视 action | 起始索引(inclusive),`delete-rows`/`delete-cols` 时必填 | +| `--to` | 视 action | 结束索引(exclusive),`delete-rows`/`delete-cols` 时必填 | +| `--markdown` | 视 action | 单元格新内容,`update-cell` 时必填 | + +## 操作说明 + +### update-cell — 修改单元格 + +通过行列索引精确定位单元格,清空原内容后写入新内容。 + +```bash +lark-cli docs +table-update --doc "" --row 1 --col 2 --markdown "更新的数据" +``` + +**注意**:当前 `update-cell` 将 markdown 作为纯文本写入。 + +### insert-row — 插入行 + +在指定位置插入一个空行。`--at 0` 在最前面插入,`--at N` 在第 N 行前插入。 + +```bash +lark-cli docs +table-update --doc "" --action insert-row --at 2 +``` + +### delete-rows — 删除行 + +删除 `[from, to)` 范围的行。 + +```bash +# 删除第2行(索引从0开始) +lark-cli docs +table-update --doc "" --action delete-rows --from 2 --to 3 +``` + +### insert-col — 插入列 + +在指定位置插入一个空列。 + +```bash +lark-cli docs +table-update --doc "" --action insert-col --at 1 +``` + +### delete-cols — 删除列 + +删除 `[from, to)` 范围的列。 + +```bash +lark-cli docs +table-update --doc "" --action delete-cols --from 1 --to 2 +``` + +## 返回值 + +```json +{ + "ok": true, + "data": { + "success": true, + "action": "update-cell", + "doc_id": "文档ID", + "table_id": "表格block_id", + "cell_id": "单元格block_id", + "row": 1, + "col": 2, + "message": "cell [1,2] updated successfully" + } +} +``` + +## 与 docs +update 的区别 + +| 特性 | `docs +update` | `docs +table-update` | +|------|---------------|---------------------| +| 操作粒度 | 文档 block 级 | 表格单元格级 | +| 表格定位 | 文本匹配(可能不精确) | 索引或 block_id(精确) | +| 行列操作 | 不支持 | 支持 insert/delete | +| 单元格编辑 | 需整表覆盖或文本替换 | 精确定位修改 | +| 适用场景 | 文档整体内容更新 | 表格内部精细编辑 | + +## 最佳实践 + +1. **先 fetch 查看表格结构**:用 `docs +fetch` 查看文档内容,确认表格索引和行列数 +2. **优先用 table-index**:通常 `--table-index 0` 即可定位第一个表格 +3. **批量修改分步执行**:每次只修改一个单元格,避免行列索引因结构变化而错位 +4. **先插入后填充**:插入行/列后,使用 `update-cell` 填充内容 + +## 参考 + +- [lark-doc-update](lark-doc-update.md) — 文档级更新 +- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数