From f5b09daf2cf29db62be180b090da8d5fd2e679fb Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Thu, 2 Jul 2026 20:56:46 +0800 Subject: [PATCH] feat(sheets): support multi-step undo --- shortcuts/sheets/data/flag-defs.json | 68 +++++++++++++++++++ shortcuts/sheets/flag_defs_gen.go | 1 + shortcuts/sheets/lark_sheet_undo.go | 24 +++++-- shortcuts/sheets/lark_sheet_undo_test.go | 25 +++++-- skills/lark-sheets/SKILL.md | 3 + .../references/lark-sheets-history.md | 20 ++++-- 6 files changed, 124 insertions(+), 17 deletions(-) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index c123aae28..5115b6eb1 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -80,6 +80,32 @@ } ] }, + "+revision-get": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, "+sheet-create": { "risk": "write", "flags": [ @@ -4941,6 +4967,14 @@ "required": "xor", "desc": "Spreadsheet locator" }, + { + "name": "count", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.", + "default": "1" + }, { "name": "dry-run", "kind": "system", @@ -4949,5 +4983,39 @@ "desc": "" } ] + }, + "+changeset-get": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "start-revision", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Start version (CS revision); the before baseline for review (must be >= 1)" + }, + { + "name": "end-revision", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", + "default": "-1" + } + ] } } diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index a3e9ff91a..4e705c0ea 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -1000,6 +1000,7 @@ var flagDefs = map[string]commandDef{ Flags: []flagDef{ {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"}, + {Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.", Default: "1"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, diff --git a/shortcuts/sheets/lark_sheet_undo.go b/shortcuts/sheets/lark_sheet_undo.go index 8327578ed..a768a1a9d 100644 --- a/shortcuts/sheets/lark_sheet_undo.go +++ b/shortcuts/sheets/lark_sheet_undo.go @@ -17,21 +17,30 @@ var Undo = common.Shortcut{ Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: historyLocatorFlags(), + // History shortcuts keep locator flags hand-written because they share + // revision/revert helpers; keep --count here in sync with sheet-skill-spec. + Flags: append(historyLocatorFlags(), + common.Flag{Name: "count", Type: "int", Default: "1", Desc: "Number of user undo stack entries to undo sequentially (1-20)."}, + ), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := resolveSpreadsheetToken(runtime) - return err + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if runtime.Int("count") < 1 || runtime.Int("count") > 20 { + return sheetsValidationForFlag("count", "--count must be between 1 and 20") + } + return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) - return invokeToolDryRun(token, ToolKindWrite, "undo_last", undoInput(token)) + return invokeToolDryRun(token, ToolKindWrite, "undo_last", undoInput(runtime, token)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetTokenExec(runtime) if err != nil { return err } - out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", undoInput(token)) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", undoInput(runtime, token)) if err != nil { return err } @@ -40,8 +49,11 @@ var Undo = common.Shortcut{ }, } -func undoInput(token string) map[string]interface{} { +func undoInput(runtime *common.RuntimeContext, token string) map[string]interface{} { + // Always send count, including the default 1, so dry-run mirrors the exact + // request body sent by Execute. return map[string]interface{}{ "excel_id": token, + "count": runtime.Int("count"), } } diff --git a/shortcuts/sheets/lark_sheet_undo_test.go b/shortcuts/sheets/lark_sheet_undo_test.go index 86ea1fc32..e796de736 100644 --- a/shortcuts/sheets/lark_sheet_undo_test.go +++ b/shortcuts/sheets/lark_sheet_undo_test.go @@ -3,7 +3,10 @@ package sheets -import "testing" +import ( + "strings" + "testing" +) func TestUndo_DryRun(t *testing.T) { t.Parallel() @@ -18,14 +21,15 @@ func TestUndo_DryRun(t *testing.T) { got := decodeToolInput(t, body, "undo_last") assertInputEquals(t, got, map[string]interface{}{ "excel_id": testToken, + "count": float64(1), }) } func TestExecute_Undo(t *testing.T) { t.Parallel() - stub := toolOutputStub(testToken, "write", `{"undone":1,"op_id":"op-1","top_doc_revision":2,"new_revision":3}`) - out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--as", "user"}, stub) + stub := toolOutputStub(testToken, "write", `{"undone":3,"op_ids":["op-3","op-2","op-1"],"results":[{"op_id":"op-3"},{"op_id":"op-2"},{"op_id":"op-1"}]}`) + out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", "3", "--as", "user"}, stub) if err != nil { t.Fatalf("execute failed: %v\nout=%s", err, out) } @@ -34,10 +38,23 @@ func TestExecute_Undo(t *testing.T) { input := decodeToolInput(t, body, "undo_last") assertInputEquals(t, input, map[string]interface{}{ "excel_id": testToken, + "count": float64(3), }) data := decodeEnvelopeData(t, out) - if data["undone"].(float64) != 1 || data["op_id"] != "op-1" { + if data["undone"].(float64) != 3 { t.Fatalf("unexpected output data: %#v", data) } } + +func TestUndo_ValidateCount(t *testing.T) { + t.Parallel() + + _, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", "21", "--as", "user"}) + if err == nil { + t.Fatal("expected count validation error") + } + if !strings.Contains(err.Error(), "--count must be between 1 and 20") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index ae234f473..ec6745f95 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -65,6 +65,8 @@ metadata: | 查找 / 替换文本 | `+cells-search`(找,关键字用 `--find`)、`+cells-replace`(替换) | `lark-sheets-search-replace` | `+cells-find`、`+find`、`--query` | | 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `lark-sheets-sheet-structure` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` | | 看工作簿 / 子表清单 | `+workbook-info` | `lark-sheets-workbook` | `+sheet-list`、`+workbook-get`、`+workbook-list` | +| 复核某次(AI)编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20) | — | +| 取当前文档 revision(版本号) | `+revision-get` | `lark-sheets-workbook` | — | | 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — | | 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `lark-sheets-workbook` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import`)、要把文件并入某个**已有在线工作簿**(给它加子表)却用它——import 只会新建独立表,加子表应走 `+sheet-copy` / `+sheet-create` | | 参考某个**已有在线表**、把多个本地文件 / 数据各作为一张子表**追加**进去(不另起独立表) | 先 `+workbook-info` 拿模板子表 `sheet_id` → `+sheet-copy` 逐张复制模板子表(公式 / 合并 / 分组底色 / 列宽 / 条件格式全继承)再用 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` 建空子表 + `+table-put --sheets/--styles` 写入 | `lark-sheets-workbook` | 把文件 `+workbook-import` / `+workbook-create` 另起一张**独立新表**(目标是并入已有工作簿时就跑偏了;这两条只产新表、不接受已有表定位) | @@ -158,6 +160,7 @@ metadata: | [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 | | [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 | | [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本、回滚到指定版本,或撤销当前用户最近一次 AI 工具写入。当用户需要查看编辑历史、回滚历史版本、查询回滚状态,或说“撤销我刚才的修改 / undo 上一步 AI 写入”时使用。历史回滚为异步全表恢复;用户维度 undo 只撤当前用户自己的 undo 栈顶。仅针对飞书表格。 | +| [Lark Sheet Changeset](references/lark-sheets-changeset.md) | 读取两个版本(CS revision)之间的 changeset(原始变更操作清单),用于复核某次编辑——尤其是 AI 编辑——是否真实满足用户诉求。传入起始版本(编辑前基线),可选结束版本(省略取最新),版本差上限 20;返回里最外层带当前表格最新版本号。当用户需要"看看这次改了什么"、"核对 AI 改动"、"对比两个版本的变更"时使用。 | ## 公共 flag 速查 diff --git a/skills/lark-sheets/references/lark-sheets-history.md b/skills/lark-sheets/references/lark-sheets-history.md index b8b3a870c..14b6c32c3 100644 --- a/skills/lark-sheets/references/lark-sheets-history.md +++ b/skills/lark-sheets/references/lark-sheets-history.md @@ -6,9 +6,9 @@ 回滚(revert)把电子表格的当前内容覆盖回某个历史版本——这是一个**写入 / 不可逆**操作,且为**异步**:发起后立即返回受理标识,真正的回滚在后台进行,需通过状态查询轮询最终结果(进行中 / 成功 / 失败)。 -`+undo` 是另一类撤销:撤销当前 CLI 用户在该电子表格里最近一次由 AI 工具产生、且尚未撤销的写入。每个用户有独立 undo 栈;多人编辑同一篇文档时,只撤当前用户的栈顶,不撤其他用户的操作。它和历史版本回滚不同,不需要选择 `history_version_id`,也不是把整表恢复到某个历史快照。 +`+undo` 是另一类撤销:撤销当前 CLI 用户在该电子表格里最近一次或最近多次由 AI 工具产生、且尚未撤销的写入。每个用户有独立 undo 栈;多人编辑同一篇文档时,只撤当前用户自己的 undo 栈,不撤其他用户的操作。它和历史版本回滚不同,不需要选择 `history_version_id`,也不是把整表恢复到某个历史快照。 -`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果;`+undo` 撤销当前用户的最近一次 AI 工具写入。 +`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果;`+undo` 撤销当前用户最近的 AI 工具写入。若只是想拿**当前文档版本号(revision)**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量。 ## 使用场景 @@ -19,11 +19,11 @@ | 查看历史版本列表 | `+history-list` | 返回 `minor_histories`,每条含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个字段;支持向前分页(可选 `--end-version`) | | 回滚到指定历史版本 | `+history-revert` | 传入 `--history-version-id`;异步受理,返回可查询标识 | | 查询回滚状态 | `+history-revert-status` | 传入 `--transaction-id`(取自 `+history-revert` 的异步受理标识);轮询某次回滚的进行中 / 成功 / 失败状态 | -| 撤销自己最近一次 AI 写入 | `+undo` | 只需 spreadsheet 定位;撤当前用户 undo 栈顶;支持普通单元格/结构写入,以及图表、透视表 update 的反向操作 | +| 撤销自己最近的 AI 写入 | `+undo` | 只需 spreadsheet 定位;默认撤当前用户 undo 栈顶;可用 `--count` 连续撤销多步;支持普通单元格/结构写入,以及图表、透视表 update 的反向操作 | 典型工作流:`+history-list` 拿到目标版本的 `history_version_id`(必要时翻页拉取更早历史)→ `+history-revert` 发起回滚并取回 `transaction_id` → `+history-revert-status --transaction-id ` 轮询直到成功或失败。 -`+undo` 的典型工作流更短:执行某个 AI 写入工具后,如果用户要求撤销刚才自己的修改,直接运行 `+undo`。返回 `undone=1` 表示已撤销一条当前用户栈顶;返回 `undone=0` 且 `reason=undo_stack_empty` 表示当前用户没有可撤销项。 +`+undo` 的典型工作流更短:执行某个 AI 写入工具后,如果用户要求撤销刚才自己的修改,直接运行 `+undo`。返回 `undone=1` 表示已撤销一条当前用户栈顶;传 `--count N` 时会按当前用户栈顶从新到旧连续撤销,返回的 `undone` 是实际撤销数量。返回 `undone=0` 且 `reason=undo_stack_empty` 表示当前用户没有可撤销项。 **注意事项(必须了解)**: - **回滚是写入 / 不可逆操作**:会用历史版本内容覆盖当前表格,发起前请确认目标 `history_version_id` 正确。 @@ -32,6 +32,7 @@ - **历史是工作簿级**:定位只需 `--url` / `--spreadsheet-token`(XOR),不需要子表选择器。 - **`+history-list` 倒序分页**:首次查省略 `--end-version`,返回最新一页;若响应里附带 `next_end_version` 与 `has_more=true`,把 `next_end_version` 作为下一次的 `--end-version` 即可继续向更早翻页;当响应**不包含**这两个字段时表示已到最早一页,不必再翻。 - **`+undo` 是用户维度**:只撤当前登录用户的 undo 栈顶;同一文档里其他用户的写入不会被撤销。 +- **`+undo --count` 是顺序多步撤销**:从当前用户最新栈项开始逐条撤销,最多 20 步;如果可撤销项不足或中途遇到不可撤销项,会停止并返回实际 `undone` 数量与 `reason`。 - **`+undo` 不区分 session / agent**:同一用户的不同 CLI 会话或 agent 共用同一个用户 undo 栈。 - **`+undo` 不进入 `+batch-update`**:撤销本身依赖用户栈状态,不能作为批量子操作嵌套执行。 @@ -74,11 +75,13 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ -_仅含公共 / 系统 flag。_ +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--count` | int | optional | 连续撤销的用户 undo 栈项数量,默认 1,最大 20。按当前用户栈顶从新到旧顺序撤销,实际撤销数量以返回的 undone 为准。 | ## Examples -公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`(XOR,二选一)。`+history-revert` 用 `--history-version-id`(取自 `+history-list`);`+history-revert-status` 用 `--transaction-id`(取自 `+history-revert` 的异步受理标识)。`+undo` 不需要 sheet-id,也不需要 history version。 +公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`(XOR,二选一)。`+history-revert` 用 `--history-version-id`(取自 `+history-list`);`+history-revert-status` 用 `--transaction-id`(取自 `+history-revert` 的异步受理标识)。`+undo` 不需要 sheet-id,也不需要 history version;连续撤销多步时传 `--count`。 ### `+history-list` @@ -113,6 +116,9 @@ lark-cli sheets +history-revert-status --url "https://sample.feishu.cn/sheets/SH # 撤销当前用户最近一次 AI 工具写入 lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" +# 连续撤销当前用户最近 3 次 AI 工具写入;实际撤销数量以返回的 undone 为准 +lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --count 3 + # 先预览将调用的底层 undo_last 请求 -lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --dry-run +lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --count 3 --dry-run ```