Compare commits

...

7 Commits

Author SHA1 Message Date
zhengzhijie
21c2e3950e feat(sheets): add chart data update shortcut 2026-07-23 17:12:08 +08:00
zhengzhijie
080fb57ad0 fix(sheets): normalize irregular chart ranges 2026-07-23 16:00:57 +08:00
zhengzhijie
87aa303ca1 fix(sheets): normalize chart range and flag inputs 2026-07-23 16:00:57 +08:00
zhengzhijie
c8f57c659e fix(sheets): prefer semantic chart shortcuts 2026-07-22 18:13:45 +08:00
zhengzhijie
e81e42029f feat(sheets): improve semantic chart workflows 2026-07-22 16:52:48 +08:00
zhengzhijie
e303da4b5e feat(sheets): add semantic chart shortcuts 2026-07-22 11:21:55 +08:00
zhengzhijie
f6bbd86303 feat(sheets): support partial chart snapshot schemas 2026-07-20 21:34:25 +08:00
12 changed files with 4957 additions and 3125 deletions

View File

@@ -223,6 +223,24 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
},
{
shortcut: "+chart-create-basic",
sc: ChartCreateBasic,
args: []string{"--sheet-id", "sh1", "--chart-type", "column", "--data-range", "A1:C10", "--title", "Sales", "--data-labels", "value", "--anchor-cell", "F2"},
subInput: `{"sheet-id":"sh1","chart-type":"column","data-range":"A1:C10","title":"Sales","data-labels":"value","anchor-cell":"F2"}`,
},
{
shortcut: "+chart-config-update",
sc: ChartConfigUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--title", "Updated", "--data-labels", "category", "--data-label-position", "top"},
subInput: `{"sheet-id":"sh1","chart-id":"c1","title":"Updated","data-labels":"category","data-label-position":"top"}`,
},
{
shortcut: "+chart-data-update",
sc: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "'Sheet1'!A1:M6", "--data-direction", "column", "--dim1-index", "1", "--dim2-indexes", "4,8"},
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"'Sheet1'!A1:M6","data-direction":"column","dim1-index":1,"dim2-indexes":"4,8"}`,
},
{
shortcut: "+pivot-create",
sc: PivotCreate,
@@ -424,6 +442,22 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
subInput: `{}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+chart-data-update invalid dim1 index",
shortcut: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "A1:C4", "--dim1-index", "0"},
subShortcut: "+chart-data-update",
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"A1:C4","dim1-index":0}`,
wantContains: "--dim1-index must be a positive 1-based index",
},
{
name: "+chart-data-update dim1 and dim2 conflict",
shortcut: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "A1:C4", "--dim1-index", "2", "--dim2-indexes", "2,3"},
subShortcut: "+chart-data-update",
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"A1:C4","dim1-index":2,"dim2-indexes":"2,3"}`,
wantContains: "--dim2-indexes must not contain the dim1 index 2",
},
{
name: "+float-image-create both image-token and image-uri",
shortcut: FloatImageCreate,
@@ -662,6 +696,18 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
"--chart-id is required",
},
{
"+chart-data-update missing --chart-id",
"+chart-data-update",
`{"sheet-id":"sh1","data-range":"A1:C4"}`,
"--chart-id is required",
},
{
"+chart-data-update missing --data-range",
"+chart-data-update",
`{"sheet-id":"sh1","chart-id":"c1"}`,
"--data-range is required",
},
{
"+filter-create missing --range",
"+filter-create",

View File

@@ -168,9 +168,12 @@ var batchOpDispatch = map[string]batchOpMapping{
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
"+chart-create-basic": {"manage_chart_object", chartCreateBasicInput},
"+chart-config-update": {"manage_chart_object", chartConfigUpdateInput},
"+chart-data-update": {"manage_chart_object", chartDataUpdateInput},
"+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)},
"+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)},

View File

@@ -3249,6 +3249,559 @@
}
]
},
"+chart-create-basic": {
"risk": "write",
"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": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-type",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Chart type",
"enum": [
"column",
"bar",
"line",
"area",
"pie",
"scatter",
"combo",
"radar"
]
},
{
"name": "data-range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"
},
{
"name": "data-direction",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data series direction; column uses the first column as categories, row uses the first row",
"default": "column",
"enum": [
"column",
"row"
]
},
{
"name": "title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart title"
},
{
"name": "subtitle",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart subtitle"
},
{
"name": "legend-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Legend position; hidden removes the legend",
"enum": [
"top",
"bottom",
"left",
"right",
"hidden"
]
},
{
"name": "x-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "X-axis title"
},
{
"name": "y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Left Y-axis title"
},
{
"name": "secondary-y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Right Y-axis title"
},
{
"name": "x-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "X-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "y-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Left Y-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "data-labels",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label content; none removes labels; category_percentage is normalized to value_percentage",
"enum": [
"none",
"value",
"percentage",
"value_percentage",
"category_percentage",
"category",
"series"
]
},
{
"name": "data-label-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label position",
"enum": [
"auto",
"top",
"bottom",
"left",
"right",
"center",
"inside",
"outside"
]
},
{
"name": "stack",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Stacking mode",
"enum": [
"none",
"normal",
"percent"
]
},
{
"name": "stacked",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Compatibility alias for --stack normal",
"hidden": true
},
{
"name": "smooth",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
},
{
"name": "color-palette",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Preset chart-level color palette; mutually exclusive with --colors",
"enum": [
"brandColorSeries@v2",
"rainbowColorSeries@v2",
"complementaryColorSeries@v2",
"converseColorSeries@v2",
"primaryColorSeries@v2",
"singleColorSeries-B-@v2",
"singleColorSeries-W-@v2",
"singleColorSeries-G-@v2",
"singleColorSeries-Y-@v2",
"singleColorSeries-O-@v2",
"singleColorSeries-R-@v2",
"singleColorSeries-D-@v2"
]
},
{
"name": "colors",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"
},
{
"name": "anchor-cell",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Optional chart anchor cell such as F2; defaults to the right of the data range"
},
{
"name": "width",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Optional chart width; must be paired with --height"
},
{
"name": "height",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Optional chart height; must be paired with --width"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-config-update": {
"risk": "write",
"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": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target chart reference_id"
},
{
"name": "title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart title"
},
{
"name": "subtitle",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart subtitle"
},
{
"name": "legend-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Legend position; hidden removes the legend",
"enum": [
"top",
"bottom",
"left",
"right",
"hidden"
]
},
{
"name": "x-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "X-axis title"
},
{
"name": "y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Left Y-axis title"
},
{
"name": "secondary-y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Right Y-axis title"
},
{
"name": "x-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "X-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "y-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Left Y-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "data-labels",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label content; none removes labels; category_percentage is normalized to value_percentage",
"enum": [
"none",
"value",
"percentage",
"value_percentage",
"category_percentage",
"category",
"series"
]
},
{
"name": "data-label-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label position",
"enum": [
"auto",
"top",
"bottom",
"left",
"right",
"center",
"inside",
"outside"
]
},
{
"name": "stack",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Stacking mode",
"enum": [
"none",
"normal",
"percent"
]
},
{
"name": "stacked",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Compatibility alias for --stack normal",
"hidden": true
},
{
"name": "smooth",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
},
{
"name": "color-palette",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Preset chart-level color palette; mutually exclusive with --colors",
"enum": [
"brandColorSeries@v2",
"rainbowColorSeries@v2",
"complementaryColorSeries@v2",
"converseColorSeries@v2",
"primaryColorSeries@v2",
"singleColorSeries-B-@v2",
"singleColorSeries-W-@v2",
"singleColorSeries-G-@v2",
"singleColorSeries-Y-@v2",
"singleColorSeries-O-@v2",
"singleColorSeries-R-@v2",
"singleColorSeries-D-@v2"
]
},
{
"name": "colors",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-data-update": {
"risk": "write",
"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": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target chart reference_id"
},
{
"name": "data-range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "New data range including headers; accepts comma-separated same-sheet ranges and normalizes misaligned or overlapping ranges"
},
{
"name": "data-direction",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data series direction; defaults to the existing chart direction when omitted",
"enum": [
"column",
"row"
]
},
{
"name": "dim1-index",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "1-based category/X-axis dimension index within the data range; defaults to the first dimension"
},
{
"name": "dim2-indexes",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Comma-separated 1-based value/Y-axis series indexes within the data range; defaults to all dimensions except dim1"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-create": {
"risk": "write",
"flags": [
@@ -3343,7 +3896,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)",
"desc": "Chart config patch JSON; send changed fields only by default; omitted fields are preserved, objects merge recursively, and arrays replace as a whole",
"input": [
"file",
"stdin"

File diff suppressed because it is too large Load Diff

View File

@@ -200,6 +200,32 @@ var flagDefs = map[string]commandDef{
{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"},
},
},
"+chart-config-update": {
Risk: "write",
Flags: []flagDef{
{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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
{Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; none removes labels; category_percentage is normalized to value_percentage", Enum: []string{"none", "value", "percentage", "value_percentage", "category_percentage", "category", "series"}},
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Data label position", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
{Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-create": {
Risk: "write",
Flags: []flagDef{
@@ -211,6 +237,52 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-create-basic": {
Risk: "write",
Flags: []flagDef{
{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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-type", Kind: "own", Type: "string", Required: "required", Desc: "Chart type", Enum: []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}},
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"},
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; column uses the first column as categories, row uses the first row", Default: "column", Enum: []string{"column", "row"}},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
{Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; none removes labels; category_percentage is normalized to value_percentage", Enum: []string{"none", "value", "percentage", "value_percentage", "category_percentage", "category", "series"}},
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Data label position", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
{Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
{Name: "anchor-cell", Kind: "own", Type: "string", Required: "optional", Desc: "Optional chart anchor cell such as F2; defaults to the right of the data range"},
{Name: "width", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart width; must be paired with --height"},
{Name: "height", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart height; must be paired with --width"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-data-update": {
Risk: "write",
Flags: []flagDef{
{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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "New data range including headers; accepts comma-separated same-sheet ranges and normalizes misaligned or overlapping ranges"},
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; defaults to the existing chart direction when omitted", Enum: []string{"column", "row"}},
{Name: "dim1-index", Kind: "own", Type: "int", Required: "optional", Desc: "1-based category/X-axis dimension index within the data range; defaults to the first dimension"},
{Name: "dim2-indexes", Kind: "own", Type: "string", Required: "optional", Desc: "Comma-separated 1-based value/Y-axis series indexes within the data range; defaults to all dimensions except dim1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
@@ -242,7 +314,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Chart config patch JSON; send changed fields only by default; omitted fields are preserved, objects merge recursively, and arrays replace as a whole", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},

View File

@@ -159,7 +159,7 @@ func splitSchemaPath(flagName string) (string, []string) {
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
// Each segment matches a key under "properties"; array levels are descended
// implicitly through "items" (an explicit "items" segment also works), and
// oneOf branches are searched for the first one carrying the key. A miss
// oneOf / anyOf branches are searched for the first one carrying the key. A miss
// errors with the keys actually available at that level so the caller can
// re-issue the path without a full dump.
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
@@ -179,7 +179,7 @@ func sliceSchemaByPath(schema interface{}, flagName string, path []string) (inte
}
// schemaChild resolves one path segment against a schema node, descending
// through items / oneOf wrappers as needed.
// through items / oneOf / anyOf wrappers as needed.
func schemaChild(node interface{}, seg string) (interface{}, bool) {
for depth := 0; depth < 8; depth++ {
m, ok := node.(map[string]interface{})
@@ -207,13 +207,20 @@ func schemaChild(node interface{}, seg string) (interface{}, bool) {
}
}
}
if branches, ok := m["anyOf"].([]interface{}); ok {
for _, b := range branches {
if child, ok := schemaChild(b, seg); ok {
return child, true
}
}
}
return nil, false
}
return nil, false
}
// schemaChildKeys lists the property keys reachable at a schema node (through
// items / oneOf wrappers), for the path-miss error.
// items / oneOf / anyOf wrappers), for the path-miss error.
func schemaChildKeys(node interface{}) []string {
seen := map[string]struct{}{}
var collect func(n interface{}, depth int)
@@ -240,6 +247,11 @@ func schemaChildKeys(node interface{}) []string {
collect(b, depth+1)
}
}
if branches, ok := m["anyOf"].([]interface{}); ok {
for _, b := range branches {
collect(b, depth+1)
}
}
}
collect(node, 0)
keys := make([]string, 0, len(seen))

View File

@@ -0,0 +1,605 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var chartHexColorPattern = regexp.MustCompile(`^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$`)
var chartSemanticConfigFlags = []string{
"title",
"subtitle",
"legend-position",
"x-axis-title",
"y-axis-title",
"secondary-y-axis-title",
"x-axis-label-angle",
"y-axis-label-angle",
"data-labels",
"data-label-position",
"stack",
"color-palette",
}
// ChartCreateBasic creates a complete server-side chart snapshot from a chart
// type and a rectangular source range. The CLI only forwards semantic input;
// it deliberately does not own or duplicate the full chart snapshot template.
var ChartCreateBasic = common.Shortcut{
Service: "sheets",
Command: "+chart-create-basic",
Description: "Create a basic chart from a chart type and data range; the server builds and validates the full snapshot.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-create-basic"),
PostMount: configureChartSemanticCommand,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartCreateBasicInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartCreateBasicInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartCreateBasicInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ChartConfigUpdate updates the common chart settings that repeatedly caused
// full-snapshot retries in eval traces. Advanced per-series and marker styling
// remains on +chart-update --properties.
var ChartConfigUpdate = common.Shortcut{
Service: "sheets",
Command: "+chart-config-update",
Description: "Update common chart titles, axes, legend, labels, stacking, smoothing, or chart-level colors without sending a snapshot.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-config-update"),
PostMount: configureChartSemanticCommand,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartConfigUpdateInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ChartDataUpdate rebinds an existing chart to a new source range. The server
// reads the current snapshot, rebuilds its data mapping, and preserves the
// chart's layout and visual configuration.
var ChartDataUpdate = common.Shortcut{
Service: "sheets",
Command: "+chart-data-update",
Description: "Update an existing chart's data range or direction while preserving its layout and visual configuration.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-data-update"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartDataUpdateInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartDataUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartDataUpdateInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func chartCreateBasicInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartType := strings.TrimSpace(rt.Str("chart-type"))
if chartType == "" {
return nil, sheetsValidationForFlag("chart-type", "--chart-type is required")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
if dataRange == "" {
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
}
direction := rt.Str("data-direction")
if direction == "" {
direction = "column"
}
normalizedDataRange, dimensionCount, dataPointCount, err := normalizeBasicChartDataRanges(dataRange, direction)
if err != nil {
return nil, err
}
if dimensionCount < 2 || dataPointCount < 2 {
return nil, sheetsValidationForFlag("data-range", "--data-range must provide at least 2 data points and 2 dimensions")
}
if chartType == "combo" && dimensionCount < 3 {
return nil, sheetsValidationForFlag("data-range", "combo chart requires at least 3 rows or columns along --data-direction")
}
basic := map[string]interface{}{
"chart_type": chartType,
"data_range": normalizedDataRange,
}
if rt.Changed("data-direction") {
basic["data_direction"] = rt.Str("data-direction")
}
if err := validateChartColorFlags(rt); err != nil {
return nil, err
}
if err := validateChartSemanticEnums(rt); err != nil {
return nil, err
}
addChartSemanticConfig(rt, basic)
if rt.Changed("anchor-cell") {
anchor := strings.TrimSpace(rt.Str("anchor-cell"))
_, row, ok := splitCellRef(anchor)
if !ok {
return nil, sheetsValidationForFlag("anchor-cell", "--anchor-cell must be a single A1 cell such as F2")
}
colEnd := 0
for colEnd < len(anchor) && ((anchor[colEnd] >= 'A' && anchor[colEnd] <= 'Z') || (anchor[colEnd] >= 'a' && anchor[colEnd] <= 'z')) {
colEnd++
}
basic["position"] = map[string]interface{}{"row": row, "col": strings.ToUpper(anchor[:colEnd])}
}
widthChanged := rt.Changed("width")
heightChanged := rt.Changed("height")
if widthChanged != heightChanged {
return nil, common.ValidationErrorf("--width and --height must be provided together").WithParams(
sheetsInvalidParam("width", "must be paired with --height"),
sheetsInvalidParam("height", "must be paired with --width"),
)
}
if widthChanged {
if rt.Int("width") < 10 || rt.Int("height") < 10 {
return nil, common.ValidationErrorf("--width and --height must be at least 10")
}
basic["size"] = map[string]interface{}{"width": rt.Int("width"), "height": rt.Int("height")}
}
input := map[string]interface{}{"excel_id": token, "operation": "create", "basic_chart": basic}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartConfigUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartID := strings.TrimSpace(rt.Str("chart-id"))
if chartID == "" {
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
}
updates := map[string]interface{}{}
if err := validateChartColorFlags(rt); err != nil {
return nil, err
}
if err := validateChartSemanticEnums(rt); err != nil {
return nil, err
}
addChartSemanticConfig(rt, updates)
if len(updates) == 0 {
return nil, common.ValidationErrorf("at least one chart configuration flag is required")
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": chartID,
"config_updates": updates,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartDataUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartID := strings.TrimSpace(rt.Str("chart-id"))
if chartID == "" {
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
if dataRange == "" {
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
}
ranges, err := splitChartDataRanges(dataRange)
if err != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
}
explicitSheet := ""
for _, value := range ranges {
item, parseErr := parseChartDataRange(value)
if parseErr != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
}
if item.sheet != "" {
if explicitSheet != "" && item.sheet != explicitSheet {
return nil, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet")
}
explicitSheet = item.sheet
}
}
updates := map[string]interface{}{"data_range": dataRange}
if rt.Changed("data-direction") {
updates["data_direction"] = rt.Str("data-direction")
}
dim1Index := 1
if rt.Changed("dim1-index") {
dim1Index = rt.Int("dim1-index")
if dim1Index < 1 {
return nil, sheetsValidationForFlag("dim1-index", "--dim1-index must be a positive 1-based index")
}
updates["dim1_index"] = dim1Index
}
if rt.Changed("dim2-indexes") {
dim2Indexes, parseErr := parseChartDim2Indexes(rt.Str("dim2-indexes"))
if parseErr != nil {
return nil, sheetsValidationForFlag("dim2-indexes", "%v", parseErr)
}
for _, index := range dim2Indexes {
if index == dim1Index {
return nil, sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes must not contain the dim1 index %d",
dim1Index,
)
}
}
updates["dim2_indexes"] = dim2Indexes
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": chartID,
"data_updates": updates,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func parseChartDim2Indexes(raw string) ([]int, error) {
parts := strings.Split(raw, ",")
indexes := make([]int, 0, len(parts))
seen := make(map[int]struct{}, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
return nil, common.ValidationErrorf("--dim2-indexes must be a comma-separated list of positive 1-based indexes")
}
index, err := strconv.Atoi(value)
if err != nil || index < 1 {
return nil, common.ValidationErrorf("--dim2-indexes must contain only positive 1-based indexes")
}
if _, exists := seen[index]; exists {
return nil, common.ValidationErrorf("--dim2-indexes must not contain duplicate index %d", index)
}
seen[index] = struct{}{}
indexes = append(indexes, index)
}
return indexes, nil
}
type chartDataRange struct {
sheet string
row, col int
rowCount, colCount int
}
func normalizeBasicChartDataRanges(dataRange, direction string) (normalized string, dimensionCount, dataPointCount int, err error) {
ranges, err := splitChartDataRanges(dataRange)
if err != nil {
return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
}
parsed := make([]chartDataRange, 0, len(ranges))
for _, value := range ranges {
item, parseErr := parseChartDataRange(value)
if parseErr != nil {
return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
}
parsed = append(parsed, item)
}
first := parsed[0]
explicitSheet := ""
spans := make([][2]int, 0, len(parsed))
aligned := true
minRow, minCol := first.row, first.col
maxRow, maxCol := first.row+first.rowCount, first.col+first.colCount
for _, item := range parsed {
if item.sheet != "" {
if explicitSheet != "" && item.sheet != explicitSheet {
return "", 0, 0, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet")
}
explicitSheet = item.sheet
}
if direction == "row" {
if item.col != first.col || item.colCount != first.colCount {
aligned = false
}
dimensionCount += item.rowCount
spans = append(spans, [2]int{item.row, item.row + item.rowCount})
} else {
if item.row != first.row || item.rowCount != first.rowCount {
aligned = false
}
dimensionCount += item.colCount
spans = append(spans, [2]int{item.col, item.col + item.colCount})
}
minRow = min(minRow, item.row)
minCol = min(minCol, item.col)
maxRow = max(maxRow, item.row+item.rowCount)
maxCol = max(maxCol, item.col+item.colCount)
}
overlapping := false
for i, current := range spans {
for j := 0; j < i; j++ {
if current[0] < spans[j][1] && spans[j][0] < current[1] {
overlapping = true
}
}
}
normalized = strings.Join(ranges, ",")
if len(ranges) > 1 && (!aligned || overlapping) {
prefix := ""
if explicitSheet != "" {
prefix = explicitSheet + "!"
}
normalized = prefix + columnIndexToLetter(minCol) + strconv.Itoa(minRow+1) + ":" + columnIndexToLetter(maxCol-1) + strconv.Itoa(maxRow)
dimensionCount = maxCol - minCol
dataPointCount = maxRow - minRow
if direction == "row" {
dimensionCount, dataPointCount = dataPointCount, dimensionCount
}
return normalized, dimensionCount, dataPointCount, nil
}
if direction == "row" {
dataPointCount = first.colCount
} else {
dataPointCount = first.rowCount
}
return normalized, dimensionCount, dataPointCount, nil
}
func splitChartDataRanges(value string) ([]string, error) {
var ranges []string
start := 0
inQuote := false
for i := 0; i <= len(value); i++ {
if i < len(value) && value[i] == '\'' {
if inQuote && i+1 < len(value) && value[i+1] == '\'' {
i++
} else {
inQuote = !inQuote
}
}
if i == len(value) || (value[i] == ',' && !inQuote) {
part := strings.TrimSpace(value[start:i])
if part == "" {
return nil, common.ValidationErrorf("range list contains an empty item")
}
ranges = append(ranges, part)
start = i + 1
}
}
if inQuote {
return nil, common.ValidationErrorf("unterminated quoted sheet name")
}
return ranges, nil
}
func parseChartDataRange(value string) (chartDataRange, error) {
item := chartDataRange{}
ref := strings.TrimSpace(value)
if bang := strings.LastIndex(ref, "!"); bang >= 0 {
item.sheet = strings.TrimSpace(ref[:bang])
ref = strings.TrimSpace(ref[bang+1:])
}
parts := strings.SplitN(ref, ":", 2)
if len(parts) != 2 {
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
}
startCol, startRow, startOK := splitCellRef(parts[0])
endCol, endRow, endOK := splitCellRef(parts[1])
if !startOK || !endOK || endCol < startCol || endRow < startRow {
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
}
item.row, item.col = startRow, startCol
item.rowCount, item.colCount = endRow-startRow+1, endCol-startCol+1
return item, nil
}
func configureChartSemanticCommand(cmd *cobra.Command) {
if cmd.Flags().Lookup("stacked") == nil {
cmd.Flags().Bool("stacked", false, "compatibility alias for --stack normal")
_ = cmd.Flags().MarkHidden("stacked")
}
originalArgs := cmd.Args
cmd.Args = func(cmd *cobra.Command, args []string) error {
if len(args) == 1 && cmd.Flags().Changed("smooth") && (args[0] == "true" || args[0] == "false") {
return cmd.Flags().Set("smooth", args[0])
}
return originalArgs(cmd, args)
}
cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
message := err.Error()
if strings.Contains(message, "unknown flag: --stacked") {
return sheetsValidationForFlag("stacked", "--stacked is not supported; use --stack normal (or --stack percent for 100%% stacking)")
}
return err
})
}
func addChartSemanticConfig(rt flagView, out map[string]interface{}) {
for _, flag := range chartSemanticConfigFlags {
if !rt.Changed(flag) {
continue
}
key := strings.ReplaceAll(flag, "-", "_")
if flag == "x-axis-label-angle" || flag == "y-axis-label-angle" {
out[key] = rt.Int(flag)
} else if flag == "data-labels" && rt.Str(flag) == "category_percentage" {
out[key] = "value_percentage"
} else {
out[key] = rt.Str(flag)
}
}
if rt.Changed("stacked") {
out["stack"] = "normal"
}
if rt.Changed("smooth") {
out["smooth"] = rt.Bool("smooth")
}
if rt.Changed("colors") {
out["colors"] = normalizedChartColors(rt)
}
}
func validateChartSemanticEnums(rt flagView) error {
if rt.Changed("stack") && rt.Changed("stacked") {
return common.ValidationErrorf("--stack and --stacked are mutually exclusive").WithParams(
sheetsInvalidParam("stack", "cannot be used with --stacked"),
sheetsInvalidParam("stacked", "cannot be used with --stack"),
)
}
return nil
}
func validateChartColorFlags(rt flagView) error {
if rt.Changed("color-palette") && rt.Changed("colors") {
return common.ValidationErrorf("--color-palette and --colors are mutually exclusive").WithParams(
sheetsInvalidParam("color-palette", "cannot be used with --colors"),
sheetsInvalidParam("colors", "cannot be used with --color-palette"),
)
}
if rt.Changed("colors") {
colors := normalizedChartColors(rt)
if len(colors) < 2 {
return sheetsValidationForFlag("colors", "--colors must contain at least two hex colors")
}
for _, color := range colors {
if !chartHexColorPattern.MatchString(color) {
return sheetsValidationForFlag("colors", "--colors contains invalid hex color %q", color)
}
}
}
return nil
}
func normalizedChartColors(rt flagView) []string {
raw := rt.StrSlice("colors")
colors := make([]string, len(raw))
for i := range raw {
colors[i] = strings.TrimSpace(raw[i])
}
return colors
}

View File

@@ -0,0 +1,346 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import "testing"
func TestChartCreateBasic_AllTypes(t *testing.T) {
t.Parallel()
types := []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}
for _, chartType := range types {
chartType := chartType
t.Run(chartType, func(t *testing.T) {
t.Parallel()
rangeValue := "A1:C4"
if chartType == "combo" {
rangeValue = "A1:D4"
}
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", chartType,
"--data-range", rangeValue,
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "create" {
t.Fatalf("operation = %v, want create", input["operation"])
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic create must not send properties")
}
basic, _ := input["basic_chart"].(map[string]interface{})
if basic["chart_type"] != chartType || basic["data_range"] != rangeValue {
t.Fatalf("basic_chart = %#v", basic)
}
})
}
}
func TestChartCreateBasic_ConfigAndPlacement(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", "A1:C4",
"--anchor-cell", "f2",
"--width", "640",
"--height", "360",
"--title", "Trend",
"--legend-position", "bottom",
"--smooth=false",
"--data-direction", "row",
"--color-palette", "brandColorSeries@v2",
})
input := decodeToolInput(t, body, "manage_chart_object")
basic, _ := input["basic_chart"].(map[string]interface{})
position, _ := basic["position"].(map[string]interface{})
size, _ := basic["size"].(map[string]interface{})
if position["col"] != "F" || position["row"] != float64(1) {
t.Errorf("position = %#v, want F2 as zero-based row 1", position)
}
if size["width"] != float64(640) || size["height"] != float64(360) {
t.Errorf("size = %#v", size)
}
if basic["title"] != "Trend" || basic["legend_position"] != "bottom" || basic["smooth"] != false ||
basic["data_direction"] != "row" || basic["color_palette"] != "brandColorSeries@v2" {
t.Errorf("semantic config = %#v", basic)
}
}
func TestChartCreateBasic_MultipleAlignedRanges(t *testing.T) {
t.Parallel()
rangeValue := "'Data, 2026'!A1:A10,'Data, 2026'!K1:L10"
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", rangeValue,
})
input := decodeToolInput(t, body, "manage_chart_object")
basic := input["basic_chart"].(map[string]interface{})
if basic["data_range"] != rangeValue {
t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], rangeValue)
}
}
func TestChartCreateBasic_MergesMisalignedOrOverlappingRanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
}{
{name: "separated rows", input: "'Sheet1'!A1:M1,'Sheet1'!A3:M3", expected: "'Sheet1'!A1:M3"},
{name: "overlapping columns", input: "A1:B10,B1:C10", expected: "A1:C10"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", tt.input,
})
input := decodeToolInput(t, body, "manage_chart_object")
basic := input["basic_chart"].(map[string]interface{})
if basic["data_range"] != tt.expected {
t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], tt.expected)
}
})
}
}
func TestChartCreateBasic_RejectsCrossSheetRanges(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", "'A'!A1:A10,'B'!C1:D10",
})
if err == nil {
t.Fatal("expected cross-sheet ranges to fail")
}
}
func TestChartSemanticShortcuts_InBatchUpdate(t *testing.T) {
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+chart-create-basic","input":{"sheet-id":"sh1","chart-type":"column","data-range":"A1:C10","title":"Sales"}},
{"shortcut":"+chart-create-basic","input":{"sheet-id":"sh1","chart-type":"line","data-range":"E1:G10","title":"Trend"}}
]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops := input["operations"].([]interface{})
if len(ops) != 2 {
t.Fatalf("operations len = %d, want 2", len(ops))
}
for i, op := range ops {
item := op.(map[string]interface{})
if item["tool_name"] != "manage_chart_object" {
t.Fatalf("operations[%d].tool_name = %v", i, item["tool_name"])
}
chartInput := item["input"].(map[string]interface{})
if chartInput["operation"] != "create" {
t.Fatalf("operations[%d].input.operation = %v", i, chartInput["operation"])
}
if _, ok := chartInput["basic_chart"].(map[string]interface{}); !ok {
t.Fatalf("operations[%d].input.basic_chart = %#v", i, chartInput["basic_chart"])
}
}
}
func TestChartConfigUpdate_PartialFields(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--y-axis-title", "Revenue",
"--stack", "percent",
"--smooth=false",
"--colors", "#112233,#445566",
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "update" || input["chart_id"] != "chart-1" {
t.Fatalf("input = %#v", input)
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic update must not send properties")
}
updates, _ := input["config_updates"].(map[string]interface{})
if updates["y_axis_title"] != "Revenue" || updates["stack"] != "percent" || updates["smooth"] != false {
t.Errorf("config_updates = %#v", updates)
}
colors, _ := updates["colors"].([]interface{})
if len(colors) != 2 || colors[0] != "#112233" || colors[1] != "#445566" {
t.Errorf("config_updates.colors = %#v", updates["colors"])
}
}
func TestChartConfigUpdate_SpacedSmoothBool(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--smooth", "false",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["config_updates"].(map[string]interface{})
if updates["smooth"] != false {
t.Fatalf("config_updates.smooth = %v, want false", updates["smooth"])
}
}
func TestChartSemanticShortcuts_CompatibleAliases(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--stacked",
})
updates := decodeToolInput(t, body, "manage_chart_object")["config_updates"].(map[string]interface{})
if updates["stack"] != "normal" {
t.Fatalf("--stacked normalized stack = %v, want normal", updates["stack"])
}
body = parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-labels", "category_percentage",
})
updates = decodeToolInput(t, body, "manage_chart_object")["config_updates"].(map[string]interface{})
if updates["data_labels"] != "value_percentage" {
t.Fatalf("data-labels normalized value = %v, want value_percentage", updates["data_labels"])
}
}
func TestChartSemanticShortcuts_CompatibleAliasesInBatch(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+chart-config-update","input":{"sheet_id":"sh1","chart_id":"chart-1","stacked":true,"data_labels":"category_percentage","smooth":false}}]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops := input["operations"].([]interface{})
chartInput := ops[0].(map[string]interface{})["input"].(map[string]interface{})
updates := chartInput["config_updates"].(map[string]interface{})
if updates["stack"] != "normal" || updates["data_labels"] != "value_percentage" || updates["smooth"] != false {
t.Fatalf("batch config_updates = %#v", updates)
}
}
func TestChartDataUpdate_PreservesSnapshotServerSide(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:M6",
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "update" || input["chart_id"] != "chart-1" {
t.Fatalf("input = %#v", input)
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic data update must not send properties")
}
updates, _ := input["data_updates"].(map[string]interface{})
if updates["data_range"] != "'Sheet1'!A1:M6" {
t.Errorf("data_updates = %#v", updates)
}
if _, ok := updates["data_direction"]; ok {
t.Errorf("omitted --data-direction must preserve the server-side direction: %#v", updates)
}
}
func TestChartDataUpdate_ExplicitDirectionAndMultipleRanges(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:A10,'Sheet1'!K1:L10",
"--data-direction", "column",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["data_updates"].(map[string]interface{})
if updates["data_range"] != "'Sheet1'!A1:A10,'Sheet1'!K1:L10" || updates["data_direction"] != "column" {
t.Errorf("data_updates = %#v", updates)
}
}
func TestChartDataUpdate_ExplicitSeriesIndexes(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:M6",
"--dim1-index", "1",
"--dim2-indexes", "4, 8",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["data_updates"].(map[string]interface{})
if updates["dim1_index"] != float64(1) {
t.Errorf("data_updates.dim1_index = %#v", updates["dim1_index"])
}
indexes, _ := updates["dim2_indexes"].([]interface{})
if len(indexes) != 2 || indexes[0] != float64(4) || indexes[1] != float64(8) {
t.Errorf("data_updates.dim2_indexes = %#v", updates["dim2_indexes"])
}
}
func TestChartSemanticShortcuts_Validation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
}{
{name: "unsupported type", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "donut", "--data-range", "A1:C4"}},
{name: "invalid semantic enum", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--legend-position", "diagonal"}},
{name: "range too small", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:A4"}},
{name: "combo needs two series", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "combo", "--data-range", "A1:B4"}},
{name: "invalid direction", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--data-direction", "horizontal"}},
{name: "colors need two values", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--colors", "#112233"}},
{name: "palette and colors are exclusive", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--color-palette", "brandColorSeries@v2", "--colors", "#112233,#445566"}},
{name: "size must be paired", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--width", "640"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, ChartCreateBasic, tt.args)
if err == nil {
t.Fatal("expected validation error")
}
})
}
_, _, err := runShortcutCapturingErr(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
})
if err == nil {
t.Fatal("expected config update with no changed field to fail")
}
for _, args := range [][]string{
{"--url", testURL, "--sheet-id", testSheetID, "--data-range", "A1:C4"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--data-direction", "horizontal"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim1-index", "0"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim2-indexes", "2,2"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim2-indexes", "1,2"},
} {
_, _, err = runShortcutCapturingErr(t, ChartDataUpdate, args)
if err == nil {
t.Fatalf("expected chart data update validation error for args %#v", args)
}
}
}

View File

@@ -151,6 +151,7 @@ func shortcutList() []common.Shortcut {
// Object CRUD (3 per skill)
ChartCreate, ChartUpdate, ChartDelete,
ChartCreateBasic, ChartConfigUpdate, ChartDataUpdate,
PivotCreate, PivotUpdate, PivotDelete,
CondFormatCreate, CondFormatUpdate, CondFormatDelete,
FilterCreate, FilterUpdate, FilterDelete,

View File

@@ -68,7 +68,7 @@ metadata:
| 批量清除多区域 | `+cells-batch-clear --yes`(需确认;`--scope` | `lark-sheets-batch-update` | `--target` |
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `lark-sheets-range-operations` | `--dimension`(无此 flag |
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | `lark-sheets-pivot-table` | 用 SUMIF / 本地脚本拼一张假透视表 |
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create`(先 `+chart-create --print-example <column\|bar\|line\|pie\|combo…>` 本地拿最小可用 `--properties` 模板,改 refs / index 即可用) | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | 先读 `lark-sheets-chart`;普通图用 `+chart-create-basic`,多图用一次 `+batch-update --continue-on-error`,已有图的数据源用 `+chart-data-update`、常用配置用 `+chart-config-update`;只有单系列 / 单数据点 / 高级引擎字段才用完整 `+chart-create` / `+chart-update` snapshot | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
| 条件高亮 / 数据条 / 色阶 / 重复值标记 | `+cond-format-create` | `lark-sheets-conditional-format` | `+highlight``+conditional-format`、逐格 `+cells-set-style` 硬凑 |
| 筛选 / 只看符合条件的行 | `+filter-create` | `lark-sheets-filter` | pandas filter 后覆盖写回(会毁原数据;要保存多份筛选状态用 `+filter-view-create` |
@@ -200,7 +200,7 @@ lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实
> ⚠️ **high-risk-write 命令清单(首次调用就带 `--yes`,别等 exit 10 再补;或先 `--dry-run` 预览)**`+batch-update`、`+cells-clear`、`+cells-batch-clear`、`+sheet-delete`、`+dim-delete`、`+dropdown-delete`,以及各对象删除 `+chart-delete` / `+pivot-delete` / `+cond-format-delete` / `+filter-delete` / `+filter-view-delete` / `+sparkline-delete` / `+float-image-delete`。
**Agent 使用提示**:写复合 JSON flag 前对结构不确定时,先 `--print-schema --flag-name <name>`(深层字段用点分路径切片)再构造 payload图表直接 `+chart-create --print-example <type>` 拿最小可用模板改参。reference 的 `## Schemas` 段只给一层结构。
**Agent 使用提示**:写复合 JSON flag 前对结构不确定时,先 `--print-schema --flag-name <name>`(深层字段用点分路径切片)再构造 payload图表任务必须先读 `lark-sheets-chart`:能用 `+chart-create-basic` / `+chart-data-update` / `+chart-config-update` 的语义参数就不得探查 schema 或构造 snapshot互不依赖的多图创建 / 更新用一次 `+batch-update --continue-on-error`;只有单系列、单数据点或高级引擎字段无语义参数时,才用 `+chart-create --print-example <type>` 或点分 schema 构造完整 snapshot。reference 的 `## Schemas` 段只给一层结构。
### flag 内容类型与输出约定(术语速记)

View File

@@ -5,8 +5,8 @@
`+batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束:
1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。
2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get``+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照
3. **预期条数前置断言**:涉及"批量填充 N 行""对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。
2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,单元格写入`+csv-get``+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末);图表写入用一次 `+chart-list` 核对对象数量、类型、系列和范围
3. **预期条数前置断言**:涉及"批量填充 N 行""对 M 个区域分别写入"或“每个 / 每天 / 分别各建一张图”时,先从数据数出 N、M 并写进清单;图表场景要断言 operations 中的创建数 = 独立实体图数 + 汇总图数。回读后断言实际等于预期,禁止用一张多系列汇总图替代多张独立图,也禁止交付半成品。
若本次 `+batch-update` 的任一子操作写入了公式、复制了公式模板、或导入了含公式的数据块,**回读校验之后还必须继续执行 `+formula-verify`**。`+batch-update` 的原子提交只保证“写入动作都执行了”,不保证整批公式运行结果 zero-error。
@@ -20,10 +20,11 @@
- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容)
- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`
- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合)
- 需要创建多张基础图,或统一更新多张图的数据源、标题、坐标轴、图例、标签、堆叠和平滑配置时(多个 `+chart-create-basic` / `+chart-data-update` / `+chart-config-update`
**行高列宽批量不走这里**:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态(如 `--widths '{"A":100,"C:E":120}'`,见 `lark-sheets-range-operations`一次调用原子完成map 形态不可作为 `--operations` 子操作嵌入(子操作里仍可用单区间形态 `range` + `height`/`width`)。
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求。存在依赖关系的操作保持默认严格事务(要么全成功要么整批回滚);互不依赖的多图表创建/更新使用 `--continue-on-error`,保留成功图表并根据逐项错误只重试失败项
**公式相关批处理的默认闭环**
- 写前:先读 `lark-sheets-formula-translation`,把公式改写成飞书可执行语义。
@@ -112,7 +113,7 @@ _公共URL/token无 sheet 定位) · 系统:`--yes`、`--dry-run`_
_要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_
**数组项**(类型 object
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +sheet-show-gridline / +sheet-hide-gridline / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +sheet-show-gridline / +sheet-hide-gridline / +chart-create / +chart-update / +chart-delete / +chart-create-basic / +chart-config-update / +chart-data-update / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id或 sheet_name但不含 spreadsheet token/url后者只在顶层 …
### `+cells-batch-set-style` `--border-styles`
@@ -169,6 +170,19 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
> ]
> ```
> **多图表组合**:先完成全部辅助数据,再把每张图的完整语义输入放进同一个批次,并在顶层传 `--continue-on-error`;每项同时记录精确表头范围、数据方向和预期系列数。批次完成后,每个受影响的 sheet 各调用一次 `+chart-list`,不要每创建一张图就读取、调整数据后再删除重建。若数据范围或系列数不符,用 `+chart-data-update` 修正已有图表,不要删除后重建。
>
> ```json
> [
> {"shortcut":"+chart-create-basic","input":{"sheet_name":"Sheet1","chart_type":"column","data_range":"'Sheet1'!A1:C10","title":"分类对比","anchor_cell":"F2"}},
> {"shortcut":"+chart-create-basic","input":{"sheet_name":"Sheet1","chart_type":"line","data_range":"'Sheet1'!E1:G10","title":"趋势变化","anchor_cell":"F18"}}
> ]
> ```
>
> ```bash
> lark-cli sheets +batch-update --url "..." --operations @ops.json --continue-on-error --yes
> ```
### `+cells-batch-set-style`
多 range 应用同一组 style服务端走 `+batch-update` 原子事务):

View File

@@ -2,18 +2,30 @@
## 真对象硬约束
当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。
当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过图表创建命令创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。
## 使用场景
读写图表对象。本 reference 覆盖 4 个 shortcut
读写图表对象。基础创建和常用更新优先用语义 shortcut只在高级配置时使用原始 snapshot
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有图表 | `+chart-list` | 获取图表的类型、数据源和样式配置 |
| 创建/更新/删除图表 | `+chart-{create|update|delete}` | 对图表对象执行写入操作 |
| 按类型和范围创建基础图 | `+chart-create-basic` | 支持 column/bar/line/area/pie/scatter/combo/radar、行/列方向与整图配色;无需构造 snapshot |
| 修正已有图表的数据范围或方向 | `+chart-data-update` | 服务端重建数据映射,保留标题、样式、位置和尺寸 |
| 批量创建或更新多个独立图表 | `+batch-update --continue-on-error` | 保留成功图表,并逐项返回失败原因;只重试失败项 |
| 更新标题、轴、图例、标签、堆叠、平滑或整图配色 | `+chart-config-update` | 无需回写 snapshot未传字段保持不变 |
| 高级创建/更新、删除图表 | `+chart-{create|update|delete}` | 按系列/数据点精细设置等高级需求才使用原始 properties |
典型工作流:先读取现有图表了解配置 → 执行创建/更新/删除 → 再次读取验证结果
典型工作流:先确认表头和精确数据范围,用 `+chart-create-basic` 一次创建并尽量在同次调用中带上已知标题/轴/标签要求;创建后用 `+chart-list` 验证。已有图表的数据范围或方向错误时用 `+chart-data-update`,常用配置修正用 `+chart-config-update`。只有用户要求单个系列、数据点或高级引擎字段时,才读取现有 snapshot 并调 `+chart-update --properties`。不要为了常用配置先输出整份 schema也不要删除重建已经创建成功的图表
**多图表工作流**:先完成所有辅助数据和表头,列出每张目标图的类型、精确数据范围、标题和落点;确认清单后,用一次 `+batch-update --continue-on-error` 批量执行 `+chart-create-basic`。图表之间独立时允许部分成功:按返回的逐项结果定位失败图表,只重试失败项,不要重复创建成功图表。批次后每个受影响的 sheet 各调用一次 `+chart-list`,验证数量、类型、系列和范围。数据尚未稳定时不要提前创建图表;已经成功创建的图表有数据源差异时批量使用 `+chart-data-update`,有配置差异时批量使用 `+chart-config-update`,不要删除重建。
**数量词必须展开**:用户说“每个 / 每天 / 分别 / 逐一 / 各一张图”时,先从数据中数出实体数 `N`,把这 `N` 张图逐项写进清单,再加上其它汇总图得到目标总数 `M`;一个包含全部实体的多系列图不能替代这 `N` 张独立图。批次前断言 operations 中恰有 `M` 个图表创建,批次后断言图表总数、逐图标题与实体集合一致。
**范围与系列前置校验**:清单中同时记录每张图的表头范围、纳入列、明确排除列、数据方向和预期系列数。表头在首列时用默认 `--data-direction column`;表头在首行、每行代表一个系列时用 `--data-direction row`。创建前根据实际表头确认边界,不凭字母猜范围;创建后范围、方向或系列数不符时,使用 `+chart-data-update` 修正,服务端会重建 `refs` / `dim1` / `dim2.series` 并保留其它配置,不要删除后重建。批次跨多个 sheet 时,每个受影响的 sheet 各调用一次 `+chart-list`
**整图配色优先走语义参数**:只要求统一主题或一组系列颜色时,在创建时传 `--color-palette``--colors`,已有图表用 `+chart-config-update` 更新;二者互斥。只有指定某个系列或某个数据点的颜色时才使用原始 snapshot。
## 需求→图表类型映射(创建前必查)
@@ -29,6 +41,8 @@
**`--properties` 结构锚点(构造前必读)**`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。**构造起点优先用 `lark-cli sheets +chart-create --print-example <column|bar|line|area|pie|scatter|radar|combo>` 拿最小可用模板改参**(本地即时返回);查深层字段用点分路径切片 `--print-schema --flag-name properties.snapshot.plotArea.axes`,别整篇 dump 翻页。完整结构以 `--print-schema --flag-name properties` 为准。
**`+chart-update` 局部更新硬规则(更新前必读)**:默认只在 `--properties` 中传实际变化的字段,未传字段保持不变;不要复制并回写完整 snapshot。`snapshot` 内普通对象递归合并,`refs` / `axes` / `series` 等数组整体替换——修改数组中的一项时,应先读取当前数组、改好后只回写该完整数组,不需要携带 snapshot 的其它字段。`snapshot.data.isStaticData` 不能通过 update 改变;需要切换静态/非静态数据时删除后重建。
**常见配置错误(必须注意)**
- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column`
- **数据标签开关**`plotArea.plot.labels` 对象的**存在性即开关**——
@@ -104,6 +118,9 @@
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+chart-list` | read | 对象 |
| `+chart-create-basic` | write | 对象 |
| `+chart-config-update` | write | 对象 |
| `+chart-data-update` | write | 对象 |
| `+chart-create` | write | 对象 |
| `+chart-update` | write | 对象 |
| `+chart-delete` | high-risk-write | 对象 |
@@ -118,6 +135,69 @@ _公共四件套 · 系统:`--dry-run`_
| --- | --- | --- | --- |
| `--chart-id` | string | optional | 指定单个图表 reference_id 过滤 |
### `+chart-create-basic`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-type` | string | required | 图表类型(可选值:`column` / `bar` / `line` / `area` / `pie` / `scatter` / `combo` / `radar` |
| `--data-range` | string | required | 含表头的一个连续 A1 范围,或逗号分隔的同表多范围;对齐且不重叠时保留独立引用,否则合并为最小包围矩形 |
| `--data-direction` | string | optional | 数据系列方向column 表示首列为类别row 表示首行为类别(可选值:`column` / `row`)(默认 `column` |
| `--title` | string | optional | 图表标题 |
| `--subtitle` | string | optional | 图表副标题 |
| `--legend-position` | string | optional | 图例位置hidden 隐藏图例(可选值:`top` / `bottom` / `left` / `right` / `hidden` |
| `--x-axis-title` | string | optional | X 轴标题 |
| `--y-axis-title` | string | optional | 左 Y 轴标题 |
| `--secondary-y-axis-title` | string | optional | 右 Y 轴标题 |
| `--x-axis-label-angle` | int | optional | X 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90` |
| `--y-axis-label-angle` | int | optional | 左 Y 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90` |
| `--data-labels` | string | optional | 数据标签内容none 隐藏标签;兼容 category_percentage 并自动按 value_percentage 处理(可选值:`none` / `value` / `percentage` / `value_percentage` / `category_percentage` / `category` / `series` |
| `--data-label-position` | string | optional | 数据标签位置(可选值:`auto` / `top` / `bottom` / `left` / `right` / `center` / `inside` / `outside` |
| `--stack` | string | optional | 堆叠模式(可选值:`none` / `normal` / `percent` |
| `--stacked` | bool | optional | 兼容别名;等价于 --stack normal隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--smooth` | bool | optional | 是否使用平滑曲线;支持 --smooth=false 和 --smooth false |
| `--color-palette` | string | optional | 预设整图配色主题;与 --colors 互斥(可选值:`brandColorSeries@v2` / `rainbowColorSeries@v2` / `complementaryColorSeries@v2` / `converseColorSeries@v2` / `primaryColorSeries@v2` / `singleColorSeries-B-@v2` / `singleColorSeries-W-@v2` / `singleColorSeries-G-@v2` / `singleColorSeries-Y-@v2` / `singleColorSeries-O-@v2` / `singleColorSeries-R-@v2` / `singleColorSeries-D-@v2` |
| `--colors` | string_slice | optional | 自定义整图系列颜色,逗号分隔且至少 2 个十六进制色值;与 --color-palette 互斥 |
| `--anchor-cell` | string | optional | 可选图表锚点单元格,如 F2省略时放到数据范围右侧 |
| `--width` | int | optional | 可选图表宽度;必须与 --height 同时传 |
| `--height` | int | optional | 可选图表高度;必须与 --width 同时传 |
### `+chart-config-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--title` | string | optional | 图表标题 |
| `--subtitle` | string | optional | 图表副标题 |
| `--legend-position` | string | optional | 图例位置hidden 隐藏图例(可选值:`top` / `bottom` / `left` / `right` / `hidden` |
| `--x-axis-title` | string | optional | X 轴标题 |
| `--y-axis-title` | string | optional | 左 Y 轴标题 |
| `--secondary-y-axis-title` | string | optional | 右 Y 轴标题 |
| `--x-axis-label-angle` | int | optional | X 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90` |
| `--y-axis-label-angle` | int | optional | 左 Y 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90` |
| `--data-labels` | string | optional | 数据标签内容none 隐藏标签;兼容 category_percentage 并自动按 value_percentage 处理(可选值:`none` / `value` / `percentage` / `value_percentage` / `category_percentage` / `category` / `series` |
| `--data-label-position` | string | optional | 数据标签位置(可选值:`auto` / `top` / `bottom` / `left` / `right` / `center` / `inside` / `outside` |
| `--stack` | string | optional | 堆叠模式(可选值:`none` / `normal` / `percent` |
| `--stacked` | bool | optional | 兼容别名;等价于 --stack normal隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--smooth` | bool | optional | 是否使用平滑曲线;支持 --smooth=false 和 --smooth false |
| `--color-palette` | string | optional | 预设整图配色主题;与 --colors 互斥(可选值:`brandColorSeries@v2` / `rainbowColorSeries@v2` / `complementaryColorSeries@v2` / `converseColorSeries@v2` / `primaryColorSeries@v2` / `singleColorSeries-B-@v2` / `singleColorSeries-W-@v2` / `singleColorSeries-G-@v2` / `singleColorSeries-Y-@v2` / `singleColorSeries-O-@v2` / `singleColorSeries-R-@v2` / `singleColorSeries-D-@v2` |
| `--colors` | string_slice | optional | 自定义整图系列颜色,逗号分隔且至少 2 个十六进制色值;与 --color-palette 互斥 |
### `+chart-data-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--data-range` | string | required | 新的含表头数据范围;支持逗号分隔的同表多范围,错位或重叠时自动合并 |
| `--data-direction` | string | optional | 数据系列方向;省略时沿用现有图表方向(可选值:`column` / `row` |
| `--dim1-index` | int | optional | 类别/X 轴维度在数据范围中的 1-based 索引;省略时使用第 1 个维度 |
| `--dim2-indexes` | string | optional | 值/Y 轴系列在数据范围中的 1-based 索引,逗号分隔;省略时使用除 dim1 外的全部维度 |
### `+chart-create`
_公共四件套 · 系统:`--dry-run`_
@@ -133,7 +213,7 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--properties` | string + File + Stdin复合 JSON | required | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch |
| `--properties` | string + File + Stdin复合 JSON | required | 图表配置补丁 JSON;默认只传变化字段,未传字段保持不变;普通对象递归合并,数组整体替换 |
### `+chart-delete`
@@ -155,7 +235,7 @@ _创建/更新的图表属性_
- `position` (object?) — 必填 { row: number, col: string }
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
- `size` (object?) — 必填 { width: number, height: number }
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 }
- `snapshot` (oneOf?) — 图表快照配置
## Examples
@@ -165,6 +245,86 @@ _创建/更新的图表属性_
输出契约:返回按工作表分组的图表列表,每个图表含 `chart_id` / `position` / `details.snapshot` 等。
### `+chart-create-basic`
首列自动作为维度,后续列作为数值系列。饼图只使用第二列数值;散点图以首列为 X、后续列为 Y组合图以第二列为左轴柱后续列为右轴折线。数据范围必须包含真实表头如果类别列与数值列不连续可以给 `--data-range` 传逗号分隔的多个范围,例如 `"'Sheet1'!A1:A10,'Sheet1'!K1:L10"`。对齐且不重叠的范围会保留为独立引用,不会把 B:J 的间隔列纳入图表;错行、错列或重叠的范围会自动合并为同一工作表内的最小包围矩形。跨工作表范围仍会拒绝。如果数据子集的表头在范围外,改用高级 `+chart-create` 的 detached 模式。
```bash
# 柱形图:默认放在数据范围右侧
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type column --data-range "'Sheet1'!A1:C10" \
--title "销售额对比" --x-axis-title "品类" --y-axis-title "销售额" \
--legend-position bottom --data-labels value --data-label-position top
# 双轴组合图:首个数值列为左轴柱,其余数值列为右轴折线
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type combo --data-range "'Sheet1'!A1:D13" \
--title "价格与效率" --y-axis-title "价格" --secondary-y-axis-title "效率" \
--anchor-cell F2 --width 700 --height 400
```
多张基础图一次创建。先把所有数据准备完成,再生成 `ops.json`
```json
[
{
"shortcut": "+chart-create-basic",
"input": {
"sheet_name": "Sheet1",
"chart_type": "column",
"data_range": "'Sheet1'!A1:C10",
"title": "分类对比",
"anchor_cell": "F2"
}
},
{
"shortcut": "+chart-create-basic",
"input": {
"sheet_name": "Sheet1",
"chart_type": "line",
"data_range": "'Sheet1'!E1:G10",
"title": "趋势变化",
"anchor_cell": "F18"
}
}
]
```
```bash
lark-cli sheets +batch-update --url "..." --operations @ops.json --continue-on-error --yes
lark-cli sheets +chart-list --url "..." --sheet-name "Sheet1"
```
### `+chart-data-update`
当创建后发现漏列、范围过宽、辅助分类列发生变化、系列选择错误或数据方向错误时,只更新数据源。`--data-direction` 省略时沿用现有图表方向;新范围必须包含表头。默认使用第 1 个维度作为 dim1、其余维度作为 dim2需要精确选择时用 1-based 的 `--dim1-index` 和逗号分隔的 `--dim2-indexes`。工具返回实际采用的 `normalized_data_ranges`,随后用 `+chart-list` 验证范围和系列数。
```bash
# 把遗漏的最后一列纳入原折线图,保留标题、配色、图例和落点
lark-cli sheets +chart-data-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-range "'Sheet1'!A1:M6"
# 改用按行组织的数据源
lark-cli sheets +chart-data-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-range "'Sheet1'!A1:M6" --data-direction row
# 第 1 列作为类别,只使用第 4、8 列作为数值系列
lark-cli sheets +chart-data-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-range "'Sheet1'!A1:M6" --dim1-index 1 --dim2-indexes "4,8"
```
### `+chart-config-update`
只传需要改的字段。`--data-labels none` 会删除数据标签;`--legend-position hidden` 会隐藏图例;`--smooth=false``--smooth false` 都可显式关闭平滑曲线。为减少参数重试,`--stacked` 自动按 `--stack normal` 处理,`--data-labels category_percentage` 自动按 `value_percentage` 处理;新调用仍优先使用规范参数。
```bash
lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--title "新标题" --x-axis-label-angle -45 --legend-position right
lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-labels value_percentage --data-label-position outside --stack percent
```
### `+chart-create`
> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**1-based对应 `refs.value` 范围内的列序。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。
@@ -292,22 +452,23 @@ JSON
### `+chart-update`
**Update 三步法**(缺一步会丢字段
1. `+chart-list --chart-id <id>` 拿到完整 snapshot
2. 在拿到的 snapshot 上**局部**修改要改的字段,其余保持不变
3. 把**完整 snapshot** 整个回写到 `--properties.snapshot`
默认提交**最小 patch**。例如只修改标题时,只传标题字段:
```bash
lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--properties '{
"position":{"row":0,"col":"A"},
"size":{"width":480,"height":320},
"snapshot": <完整快照(由 +chart-list 取回后局部修改)>
"snapshot":{"title":{"text":"新的图表标题"}}
}'
```
> 关键:**不能只提交局部 snapshot**,否则未传字段会被还原为默认值。`+chart-update` 的语义是 PUT整体覆盖不是 PATCH。
只调整尺寸时,不需要传 `snapshot`
```bash
lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--properties '{"size":{"width":640,"height":360}}'
```
> 数组采用整体替换语义。比如只修改一个坐标轴时,先用 `+chart-list --chart-id <id>` 取得当前 `snapshot.plotArea.axes`,修改目标项后,仅回写 `{"snapshot":{"plotArea":{"axes":[...]}}}`;不要同时回写标题、数据源、图例等未变化字段。
### `+chart-delete`
@@ -325,8 +486,8 @@ lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+chart-create` / `+chart-update``--properties` 必须能解析为合法 JSON`+chart-delete`high-risk-write校验 `--yes``--dry-run` 至少一个。
- `DryRun``+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板"`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。
- `Validate`XOR 公共四件套;`+chart-data-update` 要求 `--chart-id``--data-range`,并校验 `--dim1-index` / `--dim2-indexes` 是正整数索引;`+chart-create` / `+chart-update``--properties` 必须能解析为合法 JSON`+chart-delete`high-risk-write校验 `--yes``--dry-run` 至少一个。
- `DryRun``+chart-data-update` / `+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板"`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。
- `Execute`:写操作执行后不自动回读;如需确认,自行调用 `+chart-list` 比对结果。
> `+chart-create` / `+chart-update` 是 write 级别,按需可用 `--dry-run` 预览,不要求 `--yes`。只有 `+chart-delete`high-risk-write必须 `--yes`。