Files
dingtalk-workspace-cli/internal/helpers/sheet.go
huwenjiao.hwjandClaude Opus 5 332b74e8ce refactor(sheet): split create-with-data and export-csv onto their own leaves
Both capabilities were added as flags on an existing leaf, and in both cases
the leaf's published interface stopped describing what the command did:

- `sheet create --values/--sheets/--styles` orchestrates create → probe →
  resolve default worksheet → write → read back → optional styles, yet the
  leaf still published `interface_mode: mcp` + `create_workspace_sheet`.
- `sheet export --export-format csv` reads `get_range_as_csv` and never
  invokes `submit_export_job`, yet the leaf published `submit_export_job`.

Each moves to its own command, declaring the interface it actually uses:
`sheet create-with-data` is `composite` with a reviewed reason and no
`interface_ref`; `sheet export-csv` is `mcp` + `get_range_as_csv`. Both are
pinned in the interface-disposition contract test.

`sheet create` and `sheet export` are restored byte-for-byte to main, so the
compatibility gates see two `command_added` additions instead of four
locked-field changes. The split also removes a user-visible trap: `--range`
without `--export-format csv` used to be silently discarded and the whole
workbook exported; the cross-format flags no longer exist, pinned by
TestSheetExportAndExportCsvFlagsDoNotLeak.

Drops the 8 now-stale mapping-ledger exclusions that covered the flags on
`sheet.create_workspace_sheet` / `sheet.submit_export_job`, shrinking this
branch's exclusion surface. Skill references and CHANGELOG follow the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:47:50 +08:00

383 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package helpers
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
// resolveSheetName 折叠 --name / --title 别名为单个值。
// 优先 --name,未设置时回退到 --title;两者都未传返回 ""。
func resolveSheetName(cmd *cobra.Command) string {
if v, _ := cmd.Flags().GetString("name"); v != "" {
return v
}
if v, _ := cmd.Flags().GetString("title"); v != "" {
return v
}
return ""
}
func newSheetCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/sheet.json
// products.sheet). Catalog assembly stamps provenance contract_final.
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sheet",
Selection: contract.ProductSelectionDecl{
AgentSummary: "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
UseWhen: []string{
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时",
},
AvoidWhen: []string{
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令",
},
},
})
root := &cobra.Command{
Use: "sheet",
Short: "钉钉表格管理",
Long: `管理钉钉在线电子表格:创建表格、工作表管理、数据读写、单元格搜索、查找替换、单元格合并与取消合并、行列插入删除移动追加与属性更新、附件上传、浮动图片管理、筛选视图管理、下拉列表管理。
命令结构:
dws sheet create 创建钉钉表格文档
dws sheet create-with-data 创建表格文档并写入初始数据(可选样式)
dws sheet list 获取全部工作表列表
dws sheet info 获取指定工作表详情
dws sheet new 新建工作表
dws sheet update 更新工作表属性
dws sheet copy 复制工作表
dws sheet range read 读取工作表数据
dws sheet range update 更新工作表指定区域内容
dws sheet range clear 清除工作表指定区域
dws sheet range sort 对工作表指定区域排序
dws sheet range fill 自动填充工作表指定区域
dws sheet range copy-to 复制区域到目标位置
dws sheet range move-to 移动区域到目标位置
dws sheet range set-style 设置指定区域的单元格样式
dws sheet range batch-set-style 按配置文件批量设置单元格样式
dws sheet find 在工作表中搜索单元格内容
dws sheet append 在工作表末尾追加数据
dws sheet csv-put 将 CSV 数据写入表格指定位置
dws sheet table-get 读取结构化 table 数据
dws sheet table-put 写入结构化 table 数据
dws sheet pivot-table [list|create|update|delete] 透视表管理
dws sheet show-gridline 显示工作表网格线
dws sheet hide-gridline 隐藏工作表网格线
dws sheet merge-cells 合并单元格
dws sheet insert-dimension 在指定位置插入行或列
dws sheet delete-dimension 删除指定位置的行或列
dws sheet update-dimension 更新指定范围行/列属性(显隐、行高/列宽)
dws sheet group-dimension 对指定连续行/列创建分组
dws sheet ungroup-dimension 取消指定连续行/列分组
dws sheet media-upload 上传附件到表格
dws sheet write-image 上传图片并写入表格单元格
dws sheet replace 全局查找替换文本
dws sheet move-dimension 移动行或列到指定位置
dws sheet add-dimension 在末尾追加空行或空列
dws sheet unmerge-cells 取消合并单元格
dws sheet set-dropdown 设置下拉列表
dws sheet get-dropdown 获取下拉列表配置
dws sheet delete-dropdown 删除下拉列表
dws sheet create-float-image 创建浮动图片
dws sheet get-float-image 获取浮动图片详情
dws sheet list-float-images 列出工作表所有浮动图片
dws sheet update-float-image 更新浮动图片属性
dws sheet delete-float-image 删除浮动图片
dws sheet filter get 获取全局筛选信息
dws sheet filter create 创建全局筛选
dws sheet filter delete 删除全局筛选
dws sheet filter update 批量更新筛选条件
dws sheet filter clear-criteria 清除单列筛选条件
dws sheet filter sort 筛选排序
dws sheet filter-view list 获取所有筛选视图
dws sheet filter-view create 创建筛选视图
dws sheet filter-view update 更新筛选视图属性
dws sheet filter-view delete 删除筛选视图
dws sheet filter-view update-criteria 更新筛选视图列条件
dws sheet filter-view delete-criteria 删除筛选视图列条件
dws sheet filter-view info 获取单个筛选视图详情
dws sheet filter-view list-criteria 列出筛选视图所有列条件
dws sheet filter-view get-criteria 获取单列筛选条件详情
dws sheet cond-format list 获取条件格式规则
dws sheet cond-format create 创建条件格式规则
dws sheet cond-format update 更新条件格式规则
dws sheet cond-format delete 删除条件格式规则
dws sheet chart list 获取浮动图表
dws sheet chart create 创建浮动图表
dws sheet chart update 更新浮动图表
dws sheet chart delete 删除浮动图表
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
dws sheet import 导入 xlsx/xls 为在线电子表格
dws sheet template list 获取表格模板列表
dws sheet template search 搜索表格模板
dws sheet template apply 应用表格模板创建新表格文档`,
}
// ── Build commands via factory functions ──────────────────────────
workbookCmds := newWorkbookCmds()
rangeCmd := newRangeCmd()
dataCmds := newDataCmds()
dimensionCmds := newDimensionCmds()
mediaCmds := newMediaCmds()
filterCmd := newFilterCmd()
filterViewCmd := newFilterViewCmd()
condFormatCmd := newCondFormatCmd()
floatImageCmds := newFloatImageCmds()
chartCmd := newChartCmd()
exportCmd := newExportCmd()
DeclareLeafMetadata(exportCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "submit_export_job",
CanonicalPath: "sheet.submit_export_job",
CLIPath: "sheet export",
PrimaryCLIPath: "sheet export",
},
Description: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载)。",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "submit_export_job"},
},
Selection: contract.SelectionSpec{
AgentSummary: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载)。",
UseWhen: []string{"需要把在线电子表格导出为 Excel 文件或拿到 downloadUrl 时"},
AvoidWhen: []string{"禁止用 range read 拼 xlsx;本地已有 xlsx 节点用 doc download;Agent 不要外层再轮询"},
Examples: []string{"dws sheet export --node <NODE_ID> --output ./report.xlsx"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
},
},
})
// 建表带初始数据、csv 同步导出都是与既有叶子不同的接口种类(一条 composite
// 编排、一条 get_range_as_csv 直连),各自独立成叶子,不挂到 create / export 上。
createWithDataCmd := newSheetCreateWithDataCmd()
exportCsvCmd := newSheetExportCsvCmd()
importCmd := newSheetImportCmd()
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
pivotTableCmd := newPivotTableCmd()
batchUpdateCmd := newBatchUpdateCmd()
DeclareLeafMetadata(batchUpdateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "batch_update",
CanonicalPath: "sheet.batch_update",
CLIPath: "sheet batch-update",
PrimaryCLIPath: "sheet batch-update",
},
Description: "批量打包多个写操作原子执行(可含清除/删除,需确认后加 --yes)。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "批量打包多个写操作原子执行(可含清除/删除,需确认后加 --yes)。",
UseWhen: []string{"用户明确要求把多个已审查写操作作为一批执行时"},
AvoidWhen: []string{"单操作请用对应原子命令;仅预览用 --dry-run,不要在未确认时加 --yes"},
Examples: []string{"dws sheet batch-update --node <NODE_ID> --operations '[{\"toolName\":\"range clear\",\"input\":{\"sheet-id\":\"Sheet1\",\"range\":\"A1:B3\",\"type\":\"content\"}}]'"},
},
},
})
rangeBatchClearCmd := newRangeBatchClearCmd()
DeclareLeafMetadata(rangeBatchClearCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "range_batch_clear",
CanonicalPath: "sheet.range_batch_clear",
CLIPath: "sheet range batch-clear",
PrimaryCLIPath: "sheet range batch-clear",
},
Description: "批量清除多个区域(原子事务,需确认后加 --yes)。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "批量清除多个区域(原子事务,需确认后加 --yes)。",
UseWhen: []string{"需要一次清除多个互不相关的区域,并希望同批提交时"},
AvoidWhen: []string{"只清一个区域用 range clear;混有写入/合并等操作用 batch-update"},
Examples: []string{"dws sheet range batch-clear --node NODE_ID --ranges '[\"Sheet1!A1:B3\",\"Sheet2!C1:D5\"]'"},
},
},
})
rangeCmd.AddCommand(newRangeSetStyleCmd(), newRangeBatchSetStyleCmd(), rangeBatchClearCmd)
// Flag registrations for batch commands
batchUpdateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
batchUpdateCmd.Flags().String("operations", "", "操作数组 JSON (必填,每项 {toolName, input})")
batchUpdateCmd.Flags().Bool("continue-on-error", false, "遇失败继续执行后续操作(默认 false,严格事务)")
rangeBatchClearCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
rangeBatchClearCmd.Flags().String("ranges", "", `目标区域 JSON 数组,每项带 sheet 前缀 (必填,如 '["Sheet1!A1:B3"]')`)
rangeBatchClearCmd.Flags().String("type", "", "清除类型: content(仅值,默认) / format(仅格式) / all(全部)")
// Collect all standalone commands for alias registration
standaloneCmds := []*cobra.Command{}
standaloneCmds = append(standaloneCmds, workbookCmds...)
standaloneCmds = append(standaloneCmds, dataCmds...)
standaloneCmds = append(standaloneCmds, dimensionCmds...)
standaloneCmds = append(standaloneCmds, mediaCmds...)
standaloneCmds = append(standaloneCmds, floatImageCmds...)
standaloneCmds = append(standaloneCmds, tableCmds...)
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
// Register cross-product aliases
for _, cmd := range standaloneCmds {
RegisterCrossProductAliases(cmd)
}
for _, parent := range []*cobra.Command{rangeCmd, filterCmd, filterViewCmd, condFormatCmd} {
for _, child := range parent.Commands() {
RegisterCrossProductAliases(child)
}
}
// Add all to root
root.AddCommand(standaloneCmds...)
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd, pivotTableCmd)
root.AddCommand(newSheetVersionCmd(), newSheetCommentCmd(), newSheetFormulaVerifyCmd())
// This is the reviewed runtime counterpart of the final Sheet Schema
// confirmation=user_required set. It is intentionally command-local: there
// is no Cobra-wide or Schema-driven interceptor. The app-level delivery gate
// verifies this exact protected set against the final typed SchemaRegistry.
confirmationGuards := []struct {
path string
operation string
targetHint string
}{
{path: "batch-update", operation: "批量更新", targetHint: "文档、子操作及影响范围"},
{path: "chart delete", operation: "删除浮动图表", targetHint: "文档、工作表和图表"},
{path: "range clear", operation: "清除工作表区域", targetHint: "工作表、清除范围和清除类型"},
{path: "cond-format delete", operation: "删除条件格式规则", targetHint: "文档、工作表和规则"},
{path: "delete-dimension", operation: "删除行或列", targetHint: "工作表、维度、起始位置和数量"},
{path: "delete-dropdown", operation: "删除下拉列表", targetHint: "工作表和单元格范围"},
{path: "filter delete", operation: "删除全局筛选", targetHint: "文档和工作表"},
{path: "filter-view delete", operation: "删除筛选视图", targetHint: "文档、工作表和筛选视图"},
{path: "delete-float-image", operation: "删除浮动图片", targetHint: "文档、工作表和浮动图片"},
{path: "pivot-table delete", operation: "删除透视表", targetHint: "文档、工作表和透视表"},
{path: "delete-sheet", operation: "删除工作表", targetHint: "文档和工作表"},
{path: "filter-view delete-criteria", operation: "删除筛选视图列条件", targetHint: "文档、工作表、筛选视图和列"},
{path: "range batch-clear", operation: "批量清除工作表区域", targetHint: "文档、工作表、清除范围和清除类型"},
{path: "range move-to", operation: "移动工作表区域", targetHint: "源工作表范围和目标位置"},
{path: "version revert", operation: "回滚表格版本", targetHint: "文档和目标版本号"},
{path: "comment delete", operation: "删除单元格评论", targetHint: "文档和评论 commentKey"},
}
for _, guard := range confirmationGuards {
attachSheetConfirmationGuard(root, guard.path, guard.operation, guard.targetHint)
}
// Guards for grouped parent commands
attachUnknownSubcommandGuard(root)
attachUnknownSubcommandGuard(rangeCmd)
attachUnknownSubcommandGuard(filterCmd)
attachUnknownSubcommandGuard(filterViewCmd)
attachUnknownSubcommandGuard(condFormatCmd)
attachUnknownSubcommandGuard(chartCmd)
attachUnknownSubcommandGuard(pivotTableCmd)
return root
}
func attachSheetConfirmationGuard(root *cobra.Command, path, operation, targetHint string) {
command, remaining, err := root.Find(strings.Fields(path))
if err != nil || command == nil || len(remaining) != 0 || command == root {
panic(fmt.Sprintf("attach Sheet confirmation guard %q: command not found (remaining=%v, err=%v)", path, remaining, err))
}
protectSheetMutationCommand(command, operation, targetHint)
}
// attachUnknownSubcommandGuard 为分组型命令挂上拼错子命令时的 did-you-mean 提示。
//
// 背景:cobra 对父命令的 Args 校验发生在 ParseFlags 之后,而 pflag 默认把未知 flag
// 当作硬错误抛出。于是 `dws sheet read --sheet-id X` 会先报 `unknown flag: --sheet-id`,
// 真正的根因(read 不是 sheet 的直接子命令)被彻底掩盖;同时 `dws sheet reead` 会被
// 当成位置参数静默吞掉、打印 help 后 exit=0,AI Agent 无法察觉命令执行失败。
//
// 本函数通过三件套让分组命令在"没匹配到子命令"时给出明确的错误与建议:
// 1. FParseErrWhitelist.UnknownFlags=true —— pflag 不再因未知 flag 中断,
// 未知 flag 连同其值一起被静默消化;
// 2. Args=ArbitraryArgs —— 允许把剩余位置参数交给 RunE 处理;
// 3. RunE —— 取 args[0] 作为拼错的子命令名,先在后代命令里查找完全同名的叶子
// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回 cobra
// 自带的同级编辑距离建议;最终返回 error 以保证 exit!=0。
//
// 仅挂在分组型父命令(sheet/range/filter-view)上,不会影响已在 cobra Find 阶段
// 精确匹配到的合法叶子命令。
func attachUnknownSubcommandGuard(cmd *cobra.Command) {
cmd.Args = cobra.ArbitraryArgs
cmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
cmd.SilenceUsage = true
// cobra 仅在 root 自动把 SuggestionsMinimumDistance 兑成 2,子命令默认为 0,
// 会导致 `sheet range reead` 这样的同级近似拼写无法触发内置建议。
if cmd.SuggestionsMinimumDistance <= 0 {
cmd.SuggestionsMinimumDistance = 2
}
cmd.RunE = func(c *cobra.Command, args []string) error {
if len(args) == 0 {
return c.Help()
}
name := args[0]
var buf strings.Builder
fmt.Fprintf(&buf, "unknown command %q for %q", name, c.CommandPath())
suggestions := deepSuggestSubcommand(c, name)
if len(suggestions) == 0 {
suggestions = c.SuggestionsFor(name)
}
if len(suggestions) > 0 {
buf.WriteString("\n\nDid you mean this?")
for _, s := range suggestions {
fmt.Fprintf(&buf, "\n\t%s %s", c.CommandPath(), s)
}
}
fmt.Fprintf(&buf, "\n\nRun '%s --help' for usage.", c.CommandPath())
return fmt.Errorf("%s", buf.String())
}
}
// deepSuggestSubcommand 在所有后代命令里查找与 name 完全同名的可用子命令,
// 返回从 parent 出发的相对路径列表。用于把 `sheet read` 这样的平铺习惯引导到
// 真实的深路径 `sheet range read`。
func deepSuggestSubcommand(parent *cobra.Command, name string) []string {
var out []string
var walk func(c *cobra.Command, rel []string)
walk = func(c *cobra.Command, rel []string) {
for _, sub := range c.Commands() {
if !sub.IsAvailableCommand() {
continue
}
next := append(append([]string{}, rel...), sub.Name())
if sub.Name() == name {
out = append(out, strings.Join(next, " "))
}
walk(sub, next)
}
}
walk(parent, nil)
return out
}