Files
dingtalk-workspace-cli/internal/helpers/sheet_export.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

290 lines
9.2 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"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
)
func runSheetExport(cmd *cobra.Command, _ []string) error {
nodeID := mustGetFlag(cmd, "node")
if nodeID == "" {
return fmt.Errorf("flag --node is required")
}
outputPath, _ := cmd.Flags().GetString("output")
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "导出钉钉表格为 xlsx")
deps.Out.PrintKeyValue("节点", nodeID)
if outputPath != "" {
deps.Out.PrintKeyValue("输出", outputPath)
}
return nil
}
ctx := context.Background()
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
// 使得 agent 无法按 JSON 解析。故 json 模式抑制进度、末尾统一输出结果 JSON。
jsonMode := deps.Caller.Format() == "json"
// Step 1: submit export job
if !jsonMode {
deps.Out.PrintInfo("[1/3] 提交表格导出任务 (xlsx)...")
}
submitText, err := callMCPToolReturnText(ctx, "submit_export_job", map[string]any{
"nodeId": nodeID,
"exportFormat": "xlsx",
})
if err != nil {
return fmt.Errorf("提交导出任务失败: %w", err)
}
jobID, err := parseExportSubmitResult(submitText)
if err != nil {
return err
}
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("导出任务已提交: jobId=%s", jobID))
// Step 2: progressive backoff polling
deps.Out.PrintInfo("[2/3] 轮询任务状态(渐进式退避,最多 30 次约 5 分钟)...")
}
downloadURL, err := pollSheetExportJob(ctx, jobID)
if err != nil {
return err
}
// No output path: print the downloadUrl and exit
if outputPath == "" {
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"success": true,
"jobId": jobID,
"downloadUrl": downloadURL,
})
}
deps.Out.PrintKeyValue("jobId", jobID)
deps.Out.PrintKeyValue("downloadUrl", downloadURL)
deps.Out.PrintInfo("导出完成。downloadUrl 具有时效性,请尽快下载。")
return nil
}
// Step 3: download to local file
// If outputPath is an existing directory, append inferred filename.
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
filename := inferSheetExportFilename(downloadURL)
if filename == "" {
filename = fmt.Sprintf("sheet-export-%s.xlsx", jobID)
}
outputPath = filepath.Join(outputPath, filename)
}
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载 xlsx 到 %s ...", outputPath))
}
if err := httpGetFile(ctx, downloadURL, map[string]string{}, outputPath); err != nil {
return fmt.Errorf("下载 xlsx 失败: %w", err)
}
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"success": true,
"jobId": jobID,
"outputPath": outputPath,
"downloadUrl": downloadURL,
})
}
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
return nil
}
// parseExportSubmitResult extracts jobId from submit_export_job MCP response.
func parseExportSubmitResult(text string) (string, error) {
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return "", fmt.Errorf("解析 submit_export_job 响应失败: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
if success, ok := data["success"].(bool); ok && !success {
msg, _ := data["message"].(string)
if msg == "" {
msg = "提交导出任务失败"
}
return "", fmt.Errorf("%s", msg)
}
jobID, _ := data["jobId"].(string)
if jobID == "" {
return "", fmt.Errorf("submit_export_job 未返回 jobId,响应: %s", text)
}
return jobID, nil
}
// exportPollIntervals returns the progressive backoff schedule defined in the
// sheet export MCP tool spec: 1~5:2s, 6~10:5s, 11~20:10s, 21~30:15s.
func exportPollIntervals() []time.Duration {
intervals := make([]time.Duration, 0, 30)
for i := 0; i < 5; i++ {
intervals = append(intervals, 2*time.Second)
}
for i := 0; i < 5; i++ {
intervals = append(intervals, 5*time.Second)
}
for i := 0; i < 10; i++ {
intervals = append(intervals, 10*time.Second)
}
for i := 0; i < 10; i++ {
intervals = append(intervals, 15*time.Second)
}
return intervals
}
// pollExportJob polls query_export_job per the progressive backoff schedule
// until the job completes successfully, fails, or the 30-attempt cap is hit.
func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
// json 模式下轮询进度也要抑制,否则 [INFO] 行会混进 stdout 破坏纯 JSON 输出。
quiet := deps.Caller.Format() == "json"
intervals := exportPollIntervals()
for i, wait := range intervals {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-helperAfter(wait):
}
text, err := callMCPToolReturnText(ctx, "query_export_job", map[string]any{
"jobId": jobID,
})
if err != nil {
if !quiet {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 查询失败,将继续轮询: %v", i+1, err))
}
continue
}
status, downloadURL, message, parseErr := parseExportQueryResult(text)
if parseErr != nil {
return "", parseErr
}
// 服务端可能返回 SUCCESS / success / Success 等不同大小写,统一归一化后再比较。
normStatus := strings.ToUpper(strings.TrimSpace(status))
switch normStatus {
case "SUCCESS":
if !quiet {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: SUCCESS", i+1))
}
if downloadURL == "" {
return "", fmt.Errorf("任务成功但未返回 downloadUrl")
}
return downloadURL, nil
case "FAILED", "FAIL", "ERROR":
if message == "" {
message = "导出任务失败"
}
return "", fmt.Errorf("%s", message)
case "PROCESSING", "RUNNING", "DOING", "PENDING", "":
if !quiet {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: PROCESSING", i+1))
}
default:
if !quiet {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: %s", i+1, status))
}
}
}
return "", fmt.Errorf("导出任务超时:已轮询 30 次(约 5 分钟)仍未完成,请稍后再试")
}
// parseExportQueryResult extracts status/downloadUrl/message from query_export_job.
func parseExportQueryResult(text string) (status, downloadURL, message string, err error) {
var data map[string]any
if e := json.Unmarshal([]byte(text), &data); e != nil {
err = fmt.Errorf("解析 query_export_job 响应失败: %w", e)
return
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
status, _ = data["status"].(string)
downloadURL, _ = data["downloadUrl"].(string)
message, _ = data["message"].(string)
return
}
// inferSheetExportFilename extracts a safe local filename from a sheet-export download URL.
func inferSheetExportFilename(rawURL string) string {
name := ""
if idx := strings.LastIndex(rawURL, "/"); idx >= 0 && idx < len(rawURL)-1 {
name = rawURL[idx+1:]
if qIdx := strings.Index(name, "?"); qIdx >= 0 {
name = name[:qIdx]
}
}
if name == "" {
return ""
}
if decoded, err := url.PathUnescape(name); err == nil && decoded != "" {
name = decoded
}
name = strings.ReplaceAll(name, "\\", "/")
name = filepath.Base(name)
if name == "" || name == "." || name == "/" {
return ""
}
return name
}
// ── export 命令定义 ──────────────────────────────────────────────────────────
func newExportCmd() *cobra.Command {
exportCmd := &cobra.Command{
Use: "export",
Short: "导出表格为 xlsx(异步任务一站式)",
Long: `将钉钉在线电子表格导出为 Office xlsx 格式(单命令一站式)。
执行流程(全程自动,无需 Agent 介入轮询):
1. 提交导出任务(submit_export_job),获取 jobId
2. 按渐进式退避策略轮询任务状态(query_export_job)
第 1~5 次:每次 2 秒
第 6~10 次:每次 5 秒
第 11~20 次:每次 10 秒
第 21~30 次:每次 15 秒
硬上限 30 次(约 5 分钟),超时后返回错误
3. 任务成功后取得 downloadUrl
4. 若指定了 --output,将 xlsx 下载到本地文件;否则直接输出 downloadUrl
参数说明:
--node 表格文档 ID 或链接 URL,系统自动识别(必填)
--output 本地保存路径(可选)。可为文件路径或目录:
- 文件路径:如 ./a.xlsx,直接按此路径保存
- 目录路径:如 ./,自动从下载链接推断文件名
- 未指定:仅返回 downloadUrl,链接有时效性请尽快下载
支持范围:
仅支持钉钉在线电子表格(axls)→ xlsx;
若需导出钉钉文字文档,请使用 dingtalkdoc 侧的导出工具。
权限要求:
当前用户对目标表格具备可查看/下载权限。`,
Example: ` # 仅导出,返回 downloadUrl(链接有时效性,请尽快下载)
dws sheet export --node NODE_ID
# 导出并自动下载为本地文件
dws sheet export --node NODE_ID --output ./report.xlsx
# --output 为目录时,自动按下载链接里的文件名保存
dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --output ./`,
RunE: runSheetExport,
}
exportCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
exportCmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录)")
return exportCmd
}