Files
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

242 lines
11 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 (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
// ============================================================================
// sheet export-csv:把单个工作表同步导出为纯 RFC4180 CSV
//
// 独立成一条命令而不是给 sheet export 加 --export-format csv:sheet export 的叶子
// 契约是 interface_mode=mcp + interface_ref=submit_export_job(xlsx 异步任务)。
// csv 是互斥分支,走的是 get_range_as_csv,submit_export_job 根本不执行;挂在同一
// 条叶子上会让 Agent 的接口审计、参数映射和后端能力判断在 csv 场景下全都拿到错误
// 信息。本叶子如实声明 interface_ref=get_range_as_csv,sheet export 则保持原样。
//
// 与 sheet csv-get 的分工:csv-get 面向 Agent 阅读,输出带 [row=N] 行号前缀并按
// -f 渲染,只回 stdout;本命令面向落盘,输出纯 CSV、可写文件,且截断时 fail-closed。
// ============================================================================
func newSheetExportCsvCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "export-csv",
Short: "导出单个工作表为纯 CSV(同步)",
Long: `将钉钉在线电子表格的单个工作表同步导出为纯 RFC4180 CSV。
整篇表格导出为 xlsx 请用 dws sheet export(异步任务:提交→轮询→可选下载)。
参数说明:
--node 表格文档 ID 或链接 URL,系统自动识别(必填)
--sheet-id 要导出的工作表 ID 或名称(不传则第一个工作表)
--range 导出范围,A1 表示法(不传则整表;大表可用此分块导出)
--value-render-option 取值模式: formatted_value(默认) / raw_value / formula
--output 本地保存路径(可选)。可为文件路径或目录(目录时存为
sheet-export.csv);不传则把 CSV 打到 stdout
--allow-truncated 允许数据被截断时仍然导出
截断行为:
表格超出单次读取上限时默认直接报错并且不写文件,避免不完整数据被当成完整导出、
或覆盖掉已有的完整文件。要接受不完整结果须显式加 --allow-truncated。
落盘保证:
写文件走同目录临时文件 + rename 原子替换,写入中途失败(磁盘满、配额、I/O 错误)
不会破坏已存在的目标文件。父目录不存在时报错,不会替你创建。
权限要求:
当前用户对目标表格具备可查看权限。`,
Example: ` # 导出第一个工作表到 stdout(可管道处理)
dws sheet export-csv --node NODE_ID
# 导出指定工作表为本地 CSV 文件
dws sheet export-csv --node NODE_ID --sheet-id SHEET_ID --output ./data.csv
# 只导出某个范围,取原始值
dws sheet export-csv --node NODE_ID --range A1:Z1000 --value-render-option raw_value`,
RunE: runSheetExportCsv,
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "export_csv",
CanonicalPath: "sheet.export_csv",
CLIPath: "sheet export-csv",
PrimaryCLIPath: "sheet export-csv",
},
Description: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "get_range_as_csv"},
},
Selection: contract.SelectionSpec{
AgentSummary: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
UseWhen: []string{"需要单个工作表的纯 CSV(写本地文件或管道处理)时"},
AvoidWhen: []string{"要整篇表格的 xlsx 用 sheet export;只是让 Agent 读内容用 sheet csv-get(带 [row=N] 行号)"},
Examples: []string{
"dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv",
"dws sheet export-csv --node <NODE_ID> --range A1:Z1000",
},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "range", Property: "range"},
{Name: "sheet-id", Property: "sheetId"},
{Name: "value-render-option", Property: "valueRenderOption"},
},
},
})
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称(不传则第一个工作表)")
cmd.Flags().String("range", "", "导出范围,A1 表示法(不传则整表;大表可用此分块导出)")
cmd.Flags().String("value-render-option", "", "取值模式: formatted_value(默认) / raw_value / formula")
cmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout")
cmd.Flags().Bool("allow-truncated", false, "允许数据被截断时仍然导出。默认截断即报错并不写文件,避免不完整数据被当成完整导出")
return cmd
}
// valueRenderOptionEnum 是 --value-render-option 的合法取值。
var valueRenderOptionEnum = map[string]bool{
"formatted_value": true, "raw_value": true, "formula": true,
}
// runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。
func runSheetExportCsv(cmd *cobra.Command, _ []string) error {
nodeID := mustGetFlag(cmd, "node")
if nodeID == "" {
return fmt.Errorf("flag --node is required")
}
sheetID, _ := cmd.Flags().GetString("sheet-id")
rangeAddr, _ := cmd.Flags().GetString("range")
valueRenderOption, _ := cmd.Flags().GetString("value-render-option")
valueRenderOption = strings.ToLower(strings.TrimSpace(valueRenderOption))
if valueRenderOption != "" && !valueRenderOptionEnum[valueRenderOption] {
return fmt.Errorf("--value-render-option 必须为 formatted_value / raw_value / formula,当前值: %s", valueRenderOption)
}
outputPath, _ := cmd.Flags().GetString("output")
allowTruncated, _ := cmd.Flags().GetBool("allow-truncated")
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "导出工作表为 CSV")
deps.Out.PrintKeyValue("节点", nodeID)
if sheetID != "" {
deps.Out.PrintKeyValue("工作表", sheetID)
}
if outputPath != "" {
deps.Out.PrintKeyValue("输出", outputPath)
}
return nil
}
ctx := context.Background()
toolArgs := map[string]any{
"nodeId": nodeID,
"annotateRowNumbers": false,
}
if sheetID != "" {
toolArgs["sheetId"] = sheetID
}
if rangeAddr != "" {
toolArgs["range"] = rangeAddr
}
if valueRenderOption != "" {
toolArgs["valueRenderOption"] = valueRenderOption
}
// CSV 正文走 stdout,进度/警告一律不能污染它。
text, err := callMCPToolReturnText(ctx, "get_range_as_csv", toolArgs)
if err != nil {
return fmt.Errorf("读取 CSV 失败: %w", err)
}
csvContent, hasMore, err := parseGetRangeAsCsvResult(text)
if err != nil {
return err
}
// 截断必须 fail-closed:只打 stderr 警告然后照常落盘 + 报"导出完成" + 退出码 0,
// 会让自动化调用方(和没留意 stderr 的人)把不完整文件当成完整导出,且若目标
// 文件已存在还会被截断数据覆盖。默认在写文件/输出之前就失败,要接受不完整结果
// 必须显式加 --allow-truncated。
if hasMore && !allowTruncated {
return fmt.Errorf("表格数据超出单次读取上限,CSV 会被截断,已中止导出(未写入 %s);"+
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...)、改用 dws sheet export 导出完整表格的 xlsx,"+
"或确认可接受不完整数据后加 --allow-truncated",
firstNonEmpty(outputPath, "stdout"))
}
if hasMore {
deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" +
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 dws sheet export 导出完整表格的 xlsx。")
}
if outputPath == "" {
deps.Out.PrintRaw(csvContent)
return nil
}
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
outputPath = filepath.Join(outputPath, "sheet-export.csv")
}
// 必须原子替换:os.WriteFile 会先把已存在的 CSV 截断,写入中途失败(磁盘满、
// 配额、I/O 错误)就把用户的原文件毁掉了。AtomicWrite 写同目录临时文件再 rename,
// 失败时原文件保持不变。
// AtomicWrite 会 MkdirAll 父目录,先探一次,保持与 sheet export 一致的「父目录
// 不存在即报错」语义,避免把拼错的路径悄悄建成目录。
if _, statErr := os.Stat(filepath.Dir(outputPath)); statErr != nil {
return fmt.Errorf("写入 CSV 文件失败: %w", statErr)
}
if err := AtomicWrite(outputPath, []byte(csvContent), 0o644); err != nil {
return fmt.Errorf("写入 CSV 文件失败: %w", err)
}
if hasMore {
deps.Out.PrintInfo(fmt.Sprintf("导出完成(数据已截断,不是完整表格): %s", outputPath))
return nil
}
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
return nil
}
// parseGetRangeAsCsvResult 从 get_range_as_csv 的 MCP 响应中提取 csv 文本与 hasMore 标志。
//
// csv 字段缺失或类型不对,必须报错而不是当成空表:调用方会把空内容写进
// --output,用 0 字节覆盖已有文件并打印"导出完成",等于静默数据丢失。
// 「字段存在且为空串」是合法的(真的空区域),与「字段缺失」区分开。
func parseGetRangeAsCsvResult(text string) (csv string, hasMore bool, err error) {
var data map[string]any
if e := json.Unmarshal([]byte(text), &data); e != nil {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: %w", e)
}
if raw, wrapped := data["result"]; wrapped {
result, ok := raw.(map[string]any)
if !ok {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: result 不是对象,响应: %s", text)
}
data = result
}
raw, exists := data["csv"]
if !exists {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: 缺少 csv 字段,响应: %s", text)
}
csvVal, ok := raw.(string)
if !ok {
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: csv 字段不是字符串(%T),响应: %s", raw, text)
}
csv = csvVal
if hm, ok := data["hasMore"].(bool); ok {
hasMore = hm
}
return csv, hasMore, nil
}