Files
dingtalk-workspace-cli/internal/helpers/sheet_chart.go

422 lines
17 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 (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
// ─── Chart properties validation ─────────────────────────────────────────────
// validChartTypes enumerates all MCP-supported chart type values (lowercase).
var validChartTypes = map[string]bool{
"column": true, "bar": true, "columnstacked": true, "barstacked": true,
"line": true, "linestacked": true,
"area": true, "areastacked": true, "areapercentstacked": true,
"pie": true, "doughnut": true,
"scatter": true, "radar": true,
}
// validateChartProperties performs basic structural validation on the --properties JSON
// before sending to MCP. It checks required fields and types without doing any conversion.
func validateChartProperties(props map[string]any) error {
// position (required)
posRaw, exists := props["position"]
if !exists || posRaw == nil {
return fmt.Errorf("--properties 缺少必填字段 position")
}
posObj, ok := posRaw.(map[string]any)
if !ok {
return fmt.Errorf("position 必须为对象")
}
if _, exists := posObj["row"]; !exists {
return fmt.Errorf("position.row 为必填字段")
}
if _, exists := posObj["col"]; !exists {
return fmt.Errorf("position.col 为必填字段")
}
// dimensions (required)
dimRaw, exists := props["dimensions"]
if !exists || dimRaw == nil {
return fmt.Errorf("--properties 缺少必填字段 dimensions")
}
dimObj, ok := dimRaw.(map[string]any)
if !ok {
return fmt.Errorf("dimensions 必须为对象")
}
if w, exists := dimObj["width"]; !exists {
return fmt.Errorf("dimensions.width 为必填字段")
} else if wv, ok := w.(float64); ok && wv <= 0 {
return fmt.Errorf("dimensions.width 必须为正数,当前值: %v", wv)
}
if h, exists := dimObj["height"]; !exists {
return fmt.Errorf("dimensions.height 为必填字段")
} else if hv, ok := h.(float64); ok && hv <= 0 {
return fmt.Errorf("dimensions.height 必须为正数,当前值: %v", hv)
}
// chart (required)
chartRaw, exists := props["chart"]
if !exists || chartRaw == nil {
return fmt.Errorf("--properties 缺少必填字段 chart")
}
chartObj, ok := chartRaw.(map[string]any)
if !ok {
return fmt.Errorf("chart 必须为对象")
}
// chart.type (required, must be valid enum)
typeRaw, exists := chartObj["type"]
if !exists || typeRaw == nil {
return fmt.Errorf("chart.type 为必填字段")
}
typeStr, ok := typeRaw.(string)
if !ok {
return fmt.Errorf("chart.type 必须为字符串")
}
if !validChartTypes[strings.ToLower(strings.TrimSpace(typeStr))] {
return fmt.Errorf("未知的图表类型: %q,可用类型: column/bar/columnStacked/barStacked/line/lineStacked/area/areaStacked/areaPercentStacked/pie/doughnut/scatter/radar", typeStr)
}
// chart.series (required, must be non-empty array)
seriesRaw, exists := chartObj["series"]
if !exists || seriesRaw == nil {
return fmt.Errorf("chart.series 为必填字段")
}
seriesArr, ok := seriesRaw.([]any)
if !ok {
return fmt.Errorf("chart.series 必须为数组")
}
if len(seriesArr) == 0 {
return fmt.Errorf("chart.series 必须至少包含一项")
}
// Validate each series item has value field
for i, item := range seriesArr {
obj, ok := item.(map[string]any)
if !ok {
return fmt.Errorf("chart.series[%d] 必须为对象", i)
}
valueRaw, exists := obj["value"]
if !exists || valueRaw == nil {
return fmt.Errorf("chart.series[%d].value 为必填字段", i)
}
valueArr, ok := valueRaw.([]any)
if !ok {
return fmt.Errorf("chart.series[%d].value 必须为数组", i)
}
if len(valueArr) == 0 {
return fmt.Errorf("chart.series[%d].value 不能为空", i)
}
}
return nil
}
// ─── Chart subcommands ──────────────────────────────────────────────────────
func newChartCmd() *cobra.Command {
chartCmd := &cobra.Command{Use: "chart", Short: "浮动图表管理"}
// ── chart list ──────────────────────────────────────────────────────
chartListCmd := &cobra.Command{
Use: "list",
Short: "获取浮动图表",
Long: `获取钉钉电子表格指定工作表中的浮动图表。
支持两种模式:
1. 列出全部图表:不传 --chart-id,返回该工作表中所有浮动图表的列表。
2. 获取单个图表:传入 --chart-id,返回该图表的详细信息。
返回内容包含每个图表的 ID、锚点位置、尺寸坐标以及完整图表配置(类型、数据系列、分类轴、标题、图例、坐标轴等)。`,
Example: ` # 列出所有浮动图表
dws sheet chart list --node NODE_ID --sheet-id SHEET_ID
# 获取单个图表详情
dws sheet chart list --node NODE_ID --sheet-id SHEET_ID --chart-id CHART_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
}
if v, _ := cmd.Flags().GetString("chart-id"); v != "" {
toolArgs["floatChartId"] = v
}
return callMCPTool("list_float_charts", toolArgs)
},
}
DeclareLeafMetadata(chartListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "chart_list",
CanonicalPath: "sheet.chart_list",
CLIPath: "sheet chart list",
PrimaryCLIPath: "sheet chart list",
},
Description: "列出工作表浮动图表。",
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: "列出工作表浮动图表。",
UseWhen: []string{"需要枚举图表 ID 以便 update/delete 时"},
AvoidWhen: []string{"创建用 chart create;AI 表格图表用 aitable chart"},
Examples: []string{"dws sheet chart list --node <NODE_ID> --sheet-id <SHEET_ID>"},
},
},
})
chartListCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
chartListCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
chartListCmd.Flags().String("chart-id", "", "浮动图表 ID (可选,不传则返回全部)")
// ── chart create ────────────────────────────────────────────────────
chartCreateCmd := &cobra.Command{
Use: "create",
Short: "创建浮动图表",
Long: `在钉钉电子表格的指定工作表中创建一个浮动图表。
--properties 为 JSON 格式,包含 position(锚点位置)、dimensions(尺寸)、chart(图表配置)三个必填字段,
以及可选的 offset(偏移量)。支持 stdin heredoc 和 @file 两种传入方式。
chart.type 支持: column(柱形图)、bar(条形图)、columnStacked(堆积柱形图)、barStacked(堆积条形图)、
line(折线图)、lineStacked(堆积折线图)、area(面积图)、areaStacked(堆积面积图)、
areaPercentStacked(百分比堆积面积图)、pie(饼图)、doughnut(环形图)、scatter(散点图)、
radar(雷达图)。
chart.series[].value / chart.series[].name / chart.category 使用 A1 区域表示法(如 "A2:B10"),
不指定 sheet 前缀时默认使用 --sheet-id 的值。
position.col 使用列字母表示法(如 "A"、"AA"),不支持数字形式。
创建成功后返回新图表的完整信息,包含系统分配的 chart-id。`,
Example: ` # 创建柱形图
dws sheet chart create --node NODE_ID --sheet-id SHEET_ID --properties '{
"position": {"row": 12, "col": "A"},
"dimensions": {"width": 600, "height": 400},
"chart": {
"type": "column",
"series": [{"name": "B1", "value": ["B2:B10"]}],
"category": ["A2:A10"],
"title": {"show": true, "text": "销售数据"}
}
}'
# 创建折线图(通过文件传入)
dws sheet chart create --node NODE_ID --sheet-id SHEET_ID --properties @chart.json
# 创建饼图
dws sheet chart create --node NODE_ID --sheet-id SHEET_ID --properties '{
"position": {"row": 0, "col": "F"},
"dimensions": {"width": 500, "height": 400},
"chart": {
"type": "pie",
"series": [{"value": ["B2:B6"]}],
"category": ["A2:A6"],
"title": {"show": true, "text": "各部门占比"}
}
}'`,
RunE: func(cmd *cobra.Command, args []string) error {
propsStr := mustGetFlag(cmd, "properties")
if propsStr == "" {
return fmt.Errorf("--properties 为必填参数")
}
if strings.HasPrefix(propsStr, "@") {
data, err := os.ReadFile(strings.TrimPrefix(propsStr, "@"))
if err != nil {
return fmt.Errorf("读取文件失败: %w", err)
}
propsStr = string(data)
}
var props map[string]any
if err := json.Unmarshal([]byte(propsStr), &props); err != nil {
return fmt.Errorf("--properties JSON 解析失败: %w", err)
}
if err := validateChartProperties(props); err != nil {
return err
}
return callMCPTool("create_float_chart", map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"properties": props,
})
},
}
DeclareLeafMetadata(chartCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "chart_create",
CanonicalPath: "sheet.chart_create",
CLIPath: "sheet chart create",
PrimaryCLIPath: "sheet chart create",
},
Description: "创建浮动图表。",
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: "创建浮动图表。",
UseWhen: []string{"需要在电子表格工作表上新建柱状/折线等浮动图表时"},
AvoidWhen: []string{"更新已有图表用 chart update;删除用 chart delete;AI 表格图表走 aitable"},
Examples: []string{"dws sheet chart create --node NODE_ID --sheet-id SHEET_ID --properties '{\"chartType\":\"bar\",\"range\":\"A1:B10\"}'"},
},
},
})
chartCreateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
chartCreateCmd.Flags().String("sheet-id", "", "工作表 ID (必填)")
chartCreateCmd.Flags().String("properties", "", "图表完整配置 JSON (必填,含 position/dimensions/chart)")
// ── chart update ────────────────────────────────────────────────────
chartUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新浮动图表",
Long: `更新钉钉电子表格中指定浮动图表的配置。
注意:本接口采用 PUT 语义(完整覆盖),非增量更新。
建议先通过 chart list(传入 --chart-id)获取当前配置,修改后整体传入。
如果只需修改部分字段(如仅修改标题),仍需传入完整的 chart 配置以避免其他字段被清空。
--properties 结构与 chart create 完全相同。`,
Example: ` # 先获取现有配置
dws sheet chart list --node NODE_ID --sheet-id SHEET_ID --chart-id CHART_ID
# 修改后整体回写
dws sheet chart update --node NODE_ID --sheet-id SHEET_ID --chart-id CHART_ID --properties '{
"position": {"row": 12, "col": "A"},
"dimensions": {"width": 800, "height": 500},
"chart": {
"type": "line",
"series": [{"name": "B1", "value": ["B2:B20"]}],
"category": ["A2:A20"],
"title": {"show": true, "text": "月度趋势(更新)"}
}
}'`,
RunE: func(cmd *cobra.Command, args []string) error {
propsStr := mustGetFlag(cmd, "properties")
if propsStr == "" {
return fmt.Errorf("--properties 为必填参数")
}
if strings.HasPrefix(propsStr, "@") {
data, err := os.ReadFile(strings.TrimPrefix(propsStr, "@"))
if err != nil {
return fmt.Errorf("读取文件失败: %w", err)
}
propsStr = string(data)
}
var props map[string]any
if err := json.Unmarshal([]byte(propsStr), &props); err != nil {
return fmt.Errorf("--properties JSON 解析失败: %w", err)
}
if err := validateChartProperties(props); err != nil {
return err
}
return callMCPTool("update_float_chart", map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"floatChartId": mustGetFlag(cmd, "chart-id"),
"properties": props,
})
},
}
DeclareLeafMetadata(chartUpdateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "chart_update",
CanonicalPath: "sheet.chart_update",
CLIPath: "sheet chart update",
PrimaryCLIPath: "sheet chart update",
},
Description: "更新浮动图表属性。",
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: "更新浮动图表属性。",
UseWhen: []string{"已知 chart-id,需要调整图表配置时"},
AvoidWhen: []string{"新建用 create;删除用 delete"},
Examples: []string{"dws sheet chart update --node <NODE_ID> --sheet-id <SHEET_ID> --chart-id <CHART_ID> --properties '{\"title\":\"新标题\"}'"},
},
},
})
chartUpdateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
chartUpdateCmd.Flags().String("sheet-id", "", "工作表 ID (必填)")
chartUpdateCmd.Flags().String("chart-id", "", "浮动图表 ID (必填,可通过 chart list 获取)")
chartUpdateCmd.Flags().String("properties", "", "图表完整配置 JSON (必填,PUT 语义整体覆盖)")
// ── chart delete ────────────────────────────────────────────────────
chartDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除浮动图表",
Long: `删除钉钉电子表格中指定的浮动图表。
注意: 这是一个危险操作,删除后图表不可恢复。执行前需要确认,或传入 --yes 跳过确认。
chart-id 可通过 chart list 获取。`,
Example: ` dws sheet chart delete --node NODE_ID --sheet-id SHEET_ID --chart-id CHART_ID --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
chartID := mustGetFlag(cmd, "chart-id")
return callMCPTool("delete_float_chart", map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"floatChartId": chartID,
})
},
}
DeclareLeafMetadata(chartDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: "chart_delete",
CanonicalPath: "sheet.chart_delete",
CLIPath: "sheet chart delete",
PrimaryCLIPath: "sheet chart delete",
},
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{"列目录用 chart list"},
Examples: []string{"dws sheet chart delete --node NODE_ID --sheet-id SHEET_ID --chart-id CHART_ID"},
},
},
})
chartDeleteCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
chartDeleteCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
chartDeleteCmd.Flags().String("chart-id", "", "浮动图表 ID (必填,可通过 chart list 获取)")
chartCmd.AddCommand(chartListCmd, chartCreateCmd, chartUpdateCmd, chartDeleteCmd)
return chartCmd
}