Compare commits

..

11 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
xiongyuanwen-byted
67fc870582 feat(sheets): add --output-path for full-read file offload on cells/csv/table-get
Reads are capped by max_chars (default 500000; the backend tool also truncates
at ~50000 when unset). Add --output-path to +cells-get / +csv-get / +table-get:
when set, the result is written to a cwd-relative path as JSON and the char cap
is lifted to unbounded, so a large sheet lands on disk in full instead of being
clipped for stdout.

+table-get previously never sent max_chars, so it silently dropped rows past the
backend ~50000 default with no signal. It now takes --max-chars (default 500000,
sent explicitly) and surfaces truncated / truncation_warning when the read is
clipped, steering callers to --output-path for a lossless full read.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
af8e027269 feat(sheets): support --include truncation on +cells-get
Map the new `truncation` value in --include to include_truncation_info on
the get_cell_ranges tool input, so +cells-get can return per-cell
isRowTruncated / isColTruncated. Flag metadata and reference synced from
sheet-skill-spec; flag_defs_gen.go regenerated.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
2efadec335 feat(sheets): cut agent error rate and --help lookups (#1911)
## Background

Round 2 of eval-driven sheets optimization, rebased onto the latest `feat/lark-sheets-develop` (`8897196d`).

## Changes

- **feat(sheets): cut agent error rate and --help lookups (eval round 2)** — targets the top failure modes from round 2 evals, reducing agent error rate and the number of `--help` lookups.
- **chore(sheets): sync skill docs and flag data from sheet-skill-spec** — syncs skill docs and flag data from sheet-skill-spec.

## Notes

- During rebase, the "import mislabeled .xls workbooks by sniffing content" fix already existed on the target branch (identical patch-id), so it was auto-skipped — no duplicate.
- The target branch was force-rewritten and advanced in the meantime; the two new commits were cleanly replayed onto the new tip via `--onto` with no conflicts. One hunk touching the `--type` description in `lark-sheets-workbook.md` was auto-dropped because upstream already has the same end state — no content lost.
2026-07-20 11:26:46 +08:00
zhanghuanxu
44514ad114 fix(slides): detect visual elements outside canvas 2026-07-19 21:45:42 +08:00
36 changed files with 7041 additions and 3413 deletions

View File

@@ -4,6 +4,10 @@
package cmdutil
import (
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
)
@@ -14,12 +18,75 @@ import (
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
// "drive.files.delete"). When the original invocation can be re-run safely,
// the hint carries the complete retry command with --yes appended — eval
// traces show agents always self-heal by appending --yes, so handing them
// the exact line saves the reconstruction step. The retry line is omitted
// (falling back to the plain hint) when any argument reads stdin (a bare "-",
// as its own token or bundled onto a flag as --flag=-, whose piped data a
// bare re-run would not reproduce) or when the rendered command would be
// unreasonably long to echo back.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action)
if retry := retryCommandWithYes(os.Args); retry != "" {
return err.WithHint("add --yes to confirm; re-run: %s", retry)
}
return err.WithHint("add --yes to confirm")
}
// retryCommandMaxLen caps the rendered retry command: past this, echoing the
// full invocation back (e.g. a +batch-update with a large inline JSON)
// costs more context than it saves.
const retryCommandMaxLen = 300
// retryCommandWithYes renders args as a shell-safe command line with --yes
// appended, or "" when a safe rendering isn't possible (see
// RequireConfirmation).
func retryCommandWithYes(args []string) string {
if len(args) == 0 {
return ""
}
parts := make([]string, 0, len(args)+1)
parts = append(parts, filepath.Base(args[0]))
for _, a := range args[1:] {
if argReadsStdin(a) {
return ""
}
parts = append(parts, shellQuoteArg(a))
}
parts = append(parts, "--yes")
line := strings.Join(parts, " ")
if len(line) > retryCommandMaxLen {
return ""
}
return line
}
// argReadsStdin reports whether an argument makes a flag read from stdin — the
// portable bare "-" value, whether passed as its own token (--flag -) or
// bundled onto the flag (--flag=- / -f=-). Piped stdin is one-shot data a bare
// re-run cannot reproduce, so any such argument suppresses the retry line.
func argReadsStdin(a string) bool {
if a == "-" {
return true
}
if strings.HasPrefix(a, "-") {
if i := strings.IndexByte(a, '='); i >= 0 && a[i+1:] == "-" {
return true
}
}
return false
}
// shellQuoteArg single-quotes an argument when it contains any character a
// POSIX shell could interpret, so the retry line is copy-paste safe.
func shellQuoteArg(s string) string {
if s == "" {
return "''"
}
if !strings.ContainsAny(s, " \t\n\"'\\$`!*?[](){}<>|&;#~") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
// The hint may additionally carry a re-run line composed from the live
// os.Args (environment-dependent under `go test`), but the add-yes
// contract always leads.
if !strings.HasPrefix(cre.Hint, "add --yes to confirm") {
t.Errorf("Hint = %q, want prefix 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
// No fix_command field leaks into the envelope: the retry line lives in
// the free-text hint only; the typed protocol stays action-only.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
@@ -78,3 +81,46 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}
// TestRetryCommandWithYes pins the retry-line contract: shell-safe quoting,
// basename argv[0], and the two omission guards (stdin args, oversized
// commands).
func TestRetryCommandWithYes(t *testing.T) {
t.Run("quotes what needs quoting and appends --yes", func(t *testing.T) {
got := retryCommandWithYes([]string{
"/usr/local/bin/lark-cli", "sheets", "+cells-clear",
"--url", "https://x.feishu.cn/sheets/tok",
"--range", "A1:B2", "--sheet-name", "第 1 班",
})
want := `lark-cli sheets +cells-clear --url https://x.feishu.cn/sheets/tok --range A1:B2 --sheet-name '第 1 班' --yes`
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("single quotes inside args survive", func(t *testing.T) {
got := retryCommandWithYes([]string{"lark-cli", "x", "--title", "it's"})
if !strings.Contains(got, `'it'\''s'`) {
t.Errorf("got %q", got)
}
})
t.Run("stdin arg omits the retry line", func(t *testing.T) {
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+batch-update", "--operations", "-"}); got != "" {
t.Errorf("stdin invocation must not render a retry line, got %q", got)
}
})
t.Run("bundled stdin flag omits the retry line", func(t *testing.T) {
// --flag=- reads stdin the same as --flag -; both must suppress the line.
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+cells-set", "--cells=-"}); got != "" {
t.Errorf("--flag=- stdin invocation must not render a retry line, got %q", got)
}
})
t.Run("oversized command omits the retry line", func(t *testing.T) {
if got := retryCommandWithYes([]string{"lark-cli", "x", "--operations", strings.Repeat("a", 400)}); got != "" {
t.Errorf("oversized invocation must not render a retry line, got %q", got)
}
})
}

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

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// ─── +chart-create --print-example ─────────────────────────────────────
//
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
// traces show agents paging through the full --print-schema dump for every
// chart (25 round trips in one 35-task batch) and still missing deep
// required fields. A ready-to-edit minimal template per chart type answers
// the actual question ("what does a valid payload look like") in one local
// call. Wired through PostMount, same pattern as +csv-put's flag-group
// tweaks — no framework change.
//
// Templates mirror the canonical examples in the lark-sheets-chart
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
// inline headerMode with refs covering the header row, 1-based indices,
// quoted sheet prefix in refs.
var chartExampleTemplates = map[string]string{
"column": chartSimpleExample("column"),
"bar": chartSimpleExample("bar"),
"line": chartSimpleExample("line"),
"area": chartSimpleExample("area"),
"radar": chartSimpleExample("radar"),
"scatter": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 400},
"snapshot": {
"title": {"text": "图表标题"},
"plotArea": {"plot": {"type": "scatter"}},
"data": {
"refs": [{"value": "'Sheet1'!A1:B20"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}]}
}
}
}`,
"pie": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 450},
"snapshot": {
"title": {"text": "占比标题"},
"plotArea": {"plot": {
"type": "pie",
"series": [{
"index": 1,
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
}]
}},
"data": {
"refs": [{"value": "'Sheet1'!A1:B11"}],
"dim1": {"serie": {"index": 1, "aggregate": true}},
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
}
}
}`,
"combo": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 700, "height": 400},
"snapshot": {
"title": {"text": "柱线组合"},
"plotArea": {"plot": {
"type": "combo",
"series": [
{"index": 2, "comboType": "column"},
{"index": 3, "comboType": "line"}
]
}},
"data": {
"refs": [{"value": "'Sheet1'!A1:C13"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}, {"index": 3}]}
}
}
}`,
}
// chartSimpleExample renders the shared minimal shape for plot types that
// need nothing beyond plot.type (column / bar / line / area / radar).
func chartSimpleExample(typ string) string {
return fmt.Sprintf(`{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 400},
"snapshot": {
"title": {"text": "图表标题"},
"plotArea": {"plot": {"type": %q}},
"data": {
"refs": [{"value": "'Sheet1'!A1:C10"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}, {"index": 3}]}
}
}
}`, typ)
}
func chartExampleTypes() []string {
types := make([]string, 0, len(chartExampleTemplates))
for t := range chartExampleTemplates {
types = append(types, t)
}
sort.Strings(types)
return types
}
// withChartPrintExample wraps +chart-create's PostMount so the command grows
// a --print-example flag that short-circuits execution and prints a minimal
// ready-to-edit --properties template — purely local, no identity or
// network. --properties' cobra-level required annotation is relaxed (the
// input builder still enforces it on the real path, same trick as
// +csv-put's --csv).
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().String("print-example", "",
"Print a minimal ready-to-edit --properties template for a chart type ("+strings.Join(chartExampleTypes(), "|")+") and exit")
// Only --properties carries a cobra-level required annotation (the
// locator flags are xor pairs, enforced later); the input builder
// still errors "--properties is required" on the real path.
if fl := cmd.Flags().Lookup("properties"); fl != nil {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
}
prevRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
typ, _ := c.Flags().GetString("print-example")
if typ == "" {
return prevRunE(c, args)
}
tmpl, ok := chartExampleTemplates[typ]
if !ok {
return common.ValidationErrorf("no example for chart type %q; available: %s",
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
}
fmt.Fprintln(c.OutOrStdout(), tmpl)
return nil
}
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
// TestChartPrintExample pins the --print-example contract: a known type
// prints its template and skips execution entirely; an unknown type lists
// the available ones.
func TestChartPrintExample(t *testing.T) {
t.Parallel()
t.Run("prints template without locator flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
parent, _, _, _ := newTestRig(t, sc)
var buf bytes.Buffer
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
if err := parent.Execute(); err != nil {
t.Fatalf("print-example should run standalone, got: %v", err)
}
if !strings.Contains(buf.String(), `"sectors"`) {
t.Errorf("pie template should carry sectors, got %q", buf.String())
}
})
t.Run("unknown type lists available", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
ve := requireValidation(t, err, `no example for chart type "donut"`)
if !strings.Contains(ve.Message, "pie") {
t.Errorf("message should list available types, got %q", ve.Message)
}
})
}
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
// template against the embedded chart-create properties schema — a template
// the CLI itself would reject is worse than none.
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
t.Parallel()
for typ, tmpl := range chartExampleTemplates {
t.Run(typ, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
t.Fatalf("template is not valid JSON: %v", err)
}
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
t.Errorf("template rejected by embedded schema: %v", err)
}
})
}
}

View File

@@ -1277,13 +1277,14 @@
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Comma-separated info categories to include",
"desc": "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)",
"enum": [
"value",
"formula",
"style",
"comment",
"data_validation"
"data_validation",
"truncation"
]
},
{
@@ -1291,9 +1292,16 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "skip-hidden",
"kind": "own",
@@ -1400,9 +1408,16 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "include-row-prefix",
"kind": "own",
@@ -1465,6 +1480,21 @@
"required": "optional",
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
},
{
"name": "max-chars",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "no-header",
"kind": "own",
@@ -3219,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": [
@@ -3313,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

@@ -75,8 +75,9 @@ 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: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -199,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{
@@ -210,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{
@@ -241,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"},
},
},
@@ -317,7 +390,8 @@ 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: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
@@ -983,6 +1057,8 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},

View File

@@ -38,9 +38,95 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
}
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
chainEnumNormalization(cmd)
chainFlagAliases(cmd)
}
}
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
//
// Eval traces show unknown-flag failures cluster on a handful of habitual
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
// the enum-normalization contract above: a name whose value semantics are
// identical to the real flag is rewritten silently (zero round-trips); a
// name whose fix changes the value or moves it into a JSON field gets a
// curated prescription on the unknown-flag error instead — never a silent
// rewrite.
// commandFlagAliases maps, per command, habitual flag names onto the flag
// actually registered. Only pairs with identical value semantics belong
// here: the rewrite is invisible, so it must be safe to apply unread
// (+csv-put --file with a path value still trips the file-path guard, which
// prescribes @file / stdin).
var commandFlagAliases = map[string]map[string]string{
"+csv-put": {"file": "csv"},
"+sheet-create": {"name": "title"},
"+cols-resize": {"cols": "range"},
"+rows-resize": {"rows": "range"},
"+range-fill": {"source": "source-range", "target": "target-range"},
"+range-copy": {"source": "source-range", "target": "target-range"},
"+range-move": {"source": "source-range", "target": "target-range"},
}
// intuitiveFlagHints carries the prescription for habitual names whose fix
// is not a 1:1 rename — the value belongs to a different flag or to a field
// inside a JSON payload. The hint spells the exact correct form so the
// retry needs no --help round trip.
var intuitiveFlagHints = map[string]map[string]string{
"+sheet-copy": {
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
},
"+dim-insert": {
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
},
"+dim-freeze": {
"frozen-rows": "freeze the first N rows with --dimension row --count N",
"frozen-cols": "freeze the first N columns with --dimension column --count N",
"frozen-columns": "freeze the first N columns with --dimension column --count N",
},
"+cells-set-style": {
"bold": "use --font-weight bold",
"italic": "use --font-style italic",
"underline": "use --font-line underline",
},
"+table-put": {
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
},
}
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
// (on top of any hook a prior PostMount installed, e.g. --token →
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
// canonical name), and the command's intuitive-alias table. Either way a
// habitual name parses as the real flag with zero round trips. Aliases
// never shadow a registered flag and never appear in --help; an alias whose
// target vanished (spec-side rename) is dropped, degrading to the
// unknown-flag prescription.
func chainFlagAliases(cmd *cobra.Command) {
aliases := commandFlagAliases[cmd.Name()]
usable := make(map[string]string, len(aliases))
for alias, target := range aliases {
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
usable[alias] = target
}
}
prev := cmd.Flags().GetNormalizeFunc()
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
if strings.Contains(name, "_") {
name = strings.ReplaceAll(name, "_", "-")
}
if target, ok := usable[name]; ok {
name = target
}
return prev(fs, name)
})
}
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
// It keeps the root behavior (typed error, did-you-mean suggestions, the
// offending flag on params) and additionally inlines the full valid-flag
@@ -67,6 +153,14 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
strings.Join(suggestions, ", "), list)
}
}
// A curated prescription beats both: it spells the exact correct form
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
if rx, ok := intuitiveFlagHints[c.Name()][name]; ok {
hint = rx
if list := inlineFlagList(valid); list != "" {
hint = rx + "; valid flags: " + list
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
@@ -139,6 +233,16 @@ var enumAliases = map[string]string{
"center": "middle", // CSS vertical-align: center → Lark "middle"
"centre": "center",
"middle": "center", // CSS-style middle → Lark horizontal "center"
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
// from the API docs; lowercased by canonicalEnumValue before lookup.
"merge_all": "all",
"merge_rows": "rows",
"merge_columns": "columns",
// Boolean-style word-wrap habits: true unambiguously means wrap on;
// false means "don't wrap", whose Lark default is overflow (word-clip is
// a distinct truncation mode nobody spells "false").
"true": "auto-wrap",
"false": "overflow",
}
// canonicalEnumValue returns the enum entry an off-vocabulary value

View File

@@ -284,9 +284,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--col-size", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--cols"`)
ve := requireValidation(t, err, `unknown flag "--col-size"`)
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
@@ -294,3 +294,158 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
}
})
}
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
// habitual name with identical value semantics parses as the real flag on a
// mounted command, costing zero round trips (eval: --cols, --file, --name,
// --source/--target each burned an unknown-flag failure plus a --help call).
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
t.Parallel()
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--width", "100",
"--dry-run",
})
if err != nil {
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
}
if !strings.Contains(stdout, "A:D") {
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
}
})
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+sheet-create")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--name", "汇总",
"--dry-run",
})
if err != nil {
t.Fatalf("--name should alias to --title and pass, got: %v", err)
}
if !strings.Contains(stdout, "汇总") {
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
}
})
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+range-fill")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--source", "B2",
"--target", "B3:B10",
"--dry-run",
})
if err != nil {
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
}
for _, want := range []string{"B2", "B3:B10"} {
if !strings.Contains(stdout, want) {
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
}
}
})
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+csv-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--start-cell", "A1",
"--file", "a,b\n1,2",
"--dry-run",
})
if err != nil {
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
}
if !strings.Contains(stdout, "a,b") {
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
}
})
t.Run("alias never shadows a registered flag", func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+csv-put"}
c.Flags().String("csv", "", "")
c.Flags().String("file", "", "") // hypothetical real flag wins
chainFlagAliases(c)
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
t.Fatalf("parse: %v", err)
}
if got, _ := c.Flags().GetString("file"); got != "x" {
t.Errorf("registered --file should keep its own value, got %q", got)
}
if got, _ := c.Flags().GetString("csv"); got != "" {
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
}
})
}
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
// names whose fix is not a rename answer with the exact correct form, so the
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
// --help calls, +dim-insert kept failing even after reading help).
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
t.Parallel()
cases := []struct {
command string
args []string
wrong string
wantHint []string
}{
{
command: "+dim-insert",
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
wrong: "--dimension",
wantHint: []string{"--position", "--count"},
},
{
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
wrong: "--frozen-rows",
wantHint: []string{"--dimension row --count N"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
wrong: "--bold",
wantHint: []string{"--font-weight bold"},
},
{
command: "+sheet-copy",
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
wrong: "--new-sheet-name",
wantHint: []string{"--title", "source sheet"},
},
{
command: "+table-put",
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
wrong: "--start-cell",
wantHint: []string{`"start_cell"`, "+csv-put"},
},
}
for _, tc := range cases {
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, tc.command)
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
for _, want := range tc.wantHint {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
_ "embed"
"encoding/json"
"sort"
"strings"
"sync"
"github.com/larksuite/cli/errs"
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
// listing of introspectable flags; otherwise it returns the schema
// subtree JSON for the named flag, or an error if the flag is not
// registered.
//
// flagName also accepts a dotted path (properties.plotArea.axes): the
// first segment names the flag, the rest walk the schema's properties
// (descending through array items implicitly), returning just that
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
// lines — otherwise force agents to page through the full dump for one
// nested field; eval traces show 25 such round trips in one batch.
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return func(flagName string) ([]byte, error) {
idx, err := loadFlagSchemas()
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return json.MarshalIndent(map[string]interface{}{
"shortcut": command,
"introspectable_flags": flags,
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
}, "", " ")
}
schema, ok := entry[flagName]
name, path := splitSchemaPath(flagName)
schema, ok := entry[name]
if !ok {
// Tolerate the wire-vocabulary underscore form (--flag-name
// border_styles for border-styles) — agents copy field names out
// of JSON payloads where underscores are canonical.
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
schema, ok = entry[alt]
}
}
if !ok {
flags := make([]string, 0, len(entry))
for f := range entry {
@@ -114,14 +131,133 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
}
sort.Strings(flags)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
WithParam("--flag-name")
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}
if err := json.Unmarshal(schema, &pretty); err != nil {
return nil, err
}
if len(path) > 0 {
pretty, err = sliceSchemaByPath(pretty, name, path)
if err != nil {
return nil, err
}
}
// Reformat for readability — schema files store compact JSON.
return json.MarshalIndent(pretty, "", " ")
}
}
// splitSchemaPath splits a --flag-name value into the flag name and the
// optional dotted schema path after it.
func splitSchemaPath(flagName string) (string, []string) {
parts := strings.Split(flagName, ".")
return parts[0], parts[1:]
}
// 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 / 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) {
node := schema
walked := flagName
for _, seg := range path {
next, ok := schemaChild(node, seg)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
WithParam("--flag-name")
}
node = next
walked += "." + seg
}
return node, nil
}
// schemaChild resolves one path segment against a schema node, descending
// 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{})
if !ok {
return nil, false
}
if seg == "items" {
if items, ok := m["items"]; ok {
return items, true
}
}
if props, ok := m["properties"].(map[string]interface{}); ok {
if child, ok := props[seg]; ok {
return child, true
}
}
if items, ok := m["items"]; ok {
node = items
continue
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
if child, ok := schemaChild(b, seg); ok {
return child, true
}
}
}
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 / anyOf wrappers), for the path-miss error.
func schemaChildKeys(node interface{}) []string {
seen := map[string]struct{}{}
var collect func(n interface{}, depth int)
collect = func(n interface{}, depth int) {
if depth > 8 {
return
}
m, ok := n.(map[string]interface{})
if !ok {
return
}
if props, ok := m["properties"].(map[string]interface{}); ok {
for k := range props {
seen[k] = struct{}{}
}
return
}
if items, ok := m["items"]; ok {
collect(items, depth+1)
return
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
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))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -407,6 +407,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Unambiguous habitual shapes are rewritten onto the wire contract
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
// so both the standalone cobra path and +batch-update sub-ops (whose
// mapFlagView.Str re-encodes composites through here) get the rewrite.
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
out = norm(out)
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
// hooks fill in flat-flag-derived fields) and any flag without an
@@ -417,6 +424,92 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
return out, nil
}
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
// input shapes onto the wire contract before schema validation — same
// contract as enum normalization: only a shape whose meaning is beyond
// doubt may be rewritten; anything ambiguous must fail with a prescription
// instead. Applied to the parsed JSON value inside parseJSONFlag.
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
"+cells-set": {"cells": wrapLoneCellObject},
"+chart-create": {"properties": normalizeChartHexColors},
"+chart-update": {"properties": normalizeChartHexColors},
}
// normalizeChartHexColors walks a chart properties payload and prefixes bare
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
// Excel-habit form the chart backend rejects with "expected rgba() or
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
// bare hex color is untouched.
func normalizeChartHexColors(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
for k, val := range t {
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
t[k] = "#" + s
continue
}
normalizeChartHexColors(val)
}
case []interface{}:
for _, e := range t {
normalizeChartHexColors(e)
}
}
return v
}
func isColorKey(k string) bool {
return k == "color" || strings.HasSuffix(k, "_color") || strings.HasSuffix(k, "Color")
}
func isBareHexColor(s string) bool {
if len(s) != 6 && len(s) != 8 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
// cellObjectKeys pins the property vocabulary of a single cell in the
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
var cellObjectKeys = map[string]struct{}{
"border_styles": {},
"cell_styles": {},
"data_validation": {},
"formula": {},
"multiple_values": {},
"note": {},
"rich_text": {},
"value": {},
}
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
// --cells contract expects. Eval traces show agents writing a single cell
// routinely pass {"value":…} without the two array layers; when every key
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
// row or a column) is returned untouched for the schema validator to
// prescribe.
func wrapLoneCellObject(v interface{}) interface{} {
obj, ok := v.(map[string]interface{})
if !ok || len(obj) == 0 {
return v
}
for k := range obj {
if _, known := cellObjectKeys[k]; !known {
return v
}
}
return []interface{}{[]interface{}{obj}}
}
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
v, err := parseJSONFlag(runtime, name)
@@ -533,8 +626,11 @@ func normalizeCellStyleAliases(style map[string]interface{}, path string) error
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
// It also expands the border "all" shorthand and intercepts border_styles
// mis-nested inside cell_styles — both server-rejected shapes that eval
// traces show surviving CLI validation and costing a full network round
// trip. Structure is checked leniently to match the pass-through contract:
// any element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
@@ -546,10 +642,18 @@ func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
if !ok {
continue
}
if bs, ok := cell["border_styles"].(map[string]interface{}); ok {
expandBorderAllShorthand(bs)
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if _, misNested := st["border_styles"]; misNested {
return common.ValidationErrorf(
"%s[%d][%d].cell_styles.border_styles is not valid — border_styles is a top-level cell field, a sibling of cell_styles; move it up one level",
path, r, c)
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
@@ -558,8 +662,29 @@ func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
return nil
}
// expandBorderAllShorthand rewrites the "all" side shorthand — habitual from
// Excel / openpyxl vocabulary, rejected by the backend — into the four
// explicit sides, in place. An explicitly set side wins over the shorthand.
// Applied on both the typed --cells path and the --styles path, so batch
// sub-ops get the same rewrite as standalone calls.
func expandBorderAllShorthand(border map[string]interface{}) {
all, ok := border["all"]
if !ok {
return
}
for _, side := range []string{"top", "bottom", "left", "right"} {
if _, exists := border[side]; !exists {
border[side] = all
}
}
delete(border, "all")
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
// left/right with style sub-objects), expanding the "all" side shorthand the
// same as the typed --cells and --styles paths so +cells-set-style /
// +cells-batch-set-style don't ship {"all":…} for the backend to reject.
// Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if runtime.Str("border-styles") == "" {
return nil, nil
@@ -572,6 +697,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if !ok {
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
expandBorderAllShorthand(m)
return m, nil
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
)
// TestWrapLoneCellObject pins the auto-wrap contract: a bare cell object —
// the classic missing-[[…]] shape agents produce for a 1×1 write — is
// rewritten to [[cell]]; anything whose meaning is not beyond doubt stays
// untouched for the schema validator to prescribe.
func TestWrapLoneCellObject(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wrapped bool
}{
{"lone value cell", `{"value":"hi"}`, true},
{"lone formula cell with styles", `{"formula":"=SUM(A1:A3)","cell_styles":{"font_weight":"bold"}}`, true},
{"unknown key stays", `{"value":"hi","range":"A1"}`, false},
{"array of cells stays (row vs column ambiguous)", `[{"value":"a"},{"value":"b"}]`, false},
{"proper 2D array stays", `[[{"value":"a"}]]`, false},
{"empty object stays", `{}`, false},
{"scalar stays", `"hi"`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tc.in), &v); err != nil {
t.Fatalf("bad fixture: %v", err)
}
out := wrapLoneCellObject(v)
_, isWrapped := out.([]interface{})
_, wasArray := v.([]interface{})
if tc.wrapped && (!isWrapped || wasArray) {
t.Errorf("expected wrap to [[cell]], got %#v", out)
}
if !tc.wrapped && !wasArray && isWrapped {
t.Errorf("expected no wrap, got %#v", out)
}
if tc.wrapped {
rows, _ := out.([]interface{})
if len(rows) != 1 {
t.Fatalf("want 1 row, got %d", len(rows))
}
cells, _ := rows[0].([]interface{})
if len(cells) != 1 {
t.Fatalf("want 1 cell, got %d", len(cells))
}
}
})
}
}
// TestCellObjectKeys_MatchEmbeddedSchema drift-guards the hardcoded cell
// vocabulary against the embedded +cells-set --cells schema: if the spec
// repo adds or removes a cell property, this fails and cellObjectKeys must
// be updated (an outdated set only narrows the auto-wrap, but silently
// narrowing is still drift).
func TestCellObjectKeys_MatchEmbeddedSchema(t *testing.T) {
t.Parallel()
idx, err := loadFlagSchemas()
if err != nil {
t.Fatalf("loadFlagSchemas: %v", err)
}
raw, ok := idx.Flags["+cells-set"]["cells"]
if !ok {
t.Fatal("embedded schema for +cells-set --cells missing")
}
var schema schemaProperty
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
cell := schema.Items
if cell != nil && cell.Items != nil {
cell = cell.Items
}
if cell == nil || len(cell.Properties) == 0 {
t.Fatal("schema shape changed: expected array→array→object with properties")
}
for k := range cell.Properties {
if _, ok := cellObjectKeys[k]; !ok {
t.Errorf("schema property %q missing from cellObjectKeys", k)
}
}
for k := range cellObjectKeys {
if _, ok := cell.Properties[k]; !ok {
t.Errorf("cellObjectKeys has %q which the schema no longer declares", k)
}
}
}
// TestCellsSet_LoneCellObjectAutoWraps runs the mounted path end-to-end: the
// eval-trace failure shape (--cells with a bare object) now dry-runs clean
// instead of failing "expected type array, got object".
func TestCellsSet_LoneCellObjectAutoWraps(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `{"value":"hello"}`,
"--dry-run",
})
if err != nil {
t.Fatalf("lone cell object should auto-wrap to [[cell]], got: %v", err)
}
if !strings.Contains(stdout, "hello") {
t.Errorf("dry-run body should carry the cell value, got %q", stdout)
}
}
// TestTablePut_SheetsDecodeHints pins the two decode-failure prescriptions:
// wrong JSON kind inlines the expected shape; mangled JSON steers to
// stdin/@file.
func TestTablePut_SheetsDecodeHints(t *testing.T) {
t.Parallel()
t.Run("type mismatch inlines skeleton", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":[{"name":"a"}],"data":[]}]}`,
"--dry-run",
})
ve := requireValidation(t, err, "--sheets: invalid JSON")
for _, want := range []string{"expected shape:", `"columns":["City","Revenue"]`, `"dtypes":{"Revenue":"float64"}`} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
t.Run("syntax error steers to stdin or @file", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[)`,
"--dry-run",
})
ve := requireValidation(t, err, "--sheets: invalid JSON")
for _, want := range []string{"stdin", "@./payload.json"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
// TestNormalizeChartHexColors pins the '#' prefixing on bare hex color
// values (eval V2U024: bars.color "4472C4" rejected server-side) and the
// pass-through of everything else, including the parseJSONFlag wiring for
// the batch sub-op path.
func TestNormalizeChartHexColors(t *testing.T) {
t.Parallel()
props := map[string]interface{}{
"plotArea": map[string]interface{}{
"plot": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{"bars": map[string]interface{}{"color": "4472C4"}},
map[string]interface{}{"line": map[string]interface{}{"color": "#ED7D31"}},
map[string]interface{}{"area": map[string]interface{}{"color": "rgba(1,2,3,0.5)"}},
map[string]interface{}{"font_color": "ED7D31AA", "label": "not a color 4472C4"},
},
},
},
}
normalizeChartHexColors(props)
series := props["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})["series"].([]interface{})
if got := series[0].(map[string]interface{})["bars"].(map[string]interface{})["color"]; got != "#4472C4" {
t.Errorf("bare hex should gain #, got %v", got)
}
if got := series[1].(map[string]interface{})["line"].(map[string]interface{})["color"]; got != "#ED7D31" {
t.Errorf("already-prefixed color must not change, got %v", got)
}
if got := series[2].(map[string]interface{})["area"].(map[string]interface{})["color"]; got != "rgba(1,2,3,0.5)" {
t.Errorf("rgba color must not change, got %v", got)
}
last := series[3].(map[string]interface{})
if got := last["font_color"]; got != "#ED7D31AA" {
t.Errorf("8-digit hex on a *_color key should gain #, got %v", got)
}
if got := last["label"]; got != "not a color 4472C4" {
t.Errorf("non-color key must not change, got %v", got)
}
// Wiring: a +chart-create sub-op style view routes through parseJSONFlag
// and picks up the normalizer.
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{
"properties": map[string]interface{}{"title": map[string]interface{}{"font_color": "112233"}},
})
out, err := parseJSONFlag(fv, "properties")
if err != nil {
t.Fatalf("parseJSONFlag: %v", err)
}
title := out.(map[string]interface{})["title"].(map[string]interface{})
if title["font_color"] != "#112233" {
t.Errorf("parseJSONFlag should apply the chart color normalizer, got %v", title["font_color"])
}
}

View File

@@ -83,6 +83,7 @@ var BatchUpdate = common.Shortcut{
return nil
},
Tips: []string{
"high-risk-write: always pass --yes (or --dry-run to preview) — without it the call exits 10 asking for confirmation.",
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
},
@@ -160,6 +161,10 @@ var CellsBatchSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-batch-set-style"),
Tips: []string{
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err

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

@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
return nil
},
Tips: []string{
"high-risk-write — always preview with --dry-run; clear is not undoable.",
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
},
}
@@ -266,9 +266,13 @@ var ColsResize = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cols-resize"),
Validate: validateViaResize("column"),
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
Tips: []string{
"Example: lark-cli sheets +cols-resize --url <URL> --sheet-name Sheet1 --range A:C --width 120",
`Different widths per column in one atomic call: --widths '{"A":80,"C:E":120}'. Widths are pixels (px ≈ chars × 8 + 16), not Excel character units.`,
},
Validate: validateViaResize("column"),
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
}
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall

View File

@@ -69,8 +69,7 @@ var CellsGet = common.Shortcut{
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
return emitReadResult(runtime, out)
},
}
@@ -88,17 +87,19 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
// read cap. Pin cell_limit very high so the tool's own default never binds
// before max_chars.
input["cell_limit"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
return input
}
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
// tool's two coarse switches:
// tool's switches:
//
// - include_styles (bool) — toggled by "style" presence
// - value_render_option (enum) — "formula" → formula; otherwise omitted
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
// the tool estimate and return per-cell isRowTruncated / isColTruncated
//
// "value", "comment", and "data_validation" are always returned by the tool
// per the schema; they have no dedicated knob today but are accepted in
@@ -119,6 +120,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
if want["formula"] {
input["value_render_option"] = "formula"
}
if want["truncation"] {
input["include_truncation_info"] = true
}
}
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
@@ -165,8 +169,7 @@ var CsvGet = common.Shortcut{
if !runtime.Bool("include-row-prefix") {
out = stripRowPrefixFromCsvOutput(out)
}
runtime.Out(out, nil)
return nil
return emitReadResult(runtime, out)
},
}
@@ -183,7 +186,7 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin
// read cap. Pin max_rows very high so the tool's own default never binds
// before max_chars.
input["max_rows"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
return input

View File

@@ -34,6 +34,22 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
},
},
{
// --include truncation toggles include_truncation_info so the tool
// estimates and returns per-cell isRowTruncated / isColTruncated.
name: "+cells-get include=truncation",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "truncation"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"A1:B2"},
"include_styles": false,
"include_truncation_info": true,
"cell_limit": float64(unboundedReadLimit),
},
},
{
// Canonical form: --sheet-id + bare --range. Aligned with
// +cells-get / +csv-get; before the e2e BUG-019 fix this

View File

@@ -128,7 +128,11 @@ var DimInsert = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-insert"),
Validate: validateViaInput(dimInsertInput),
Tips: []string{
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
},
Validate: validateViaInput(dimInsertInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -292,7 +296,10 @@ var DimFreeze = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-freeze"),
Validate: validateViaInput(dimFreezeInput),
Tips: []string{
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --dimension row --count 2 (freezes the first 2 rows; --count 0 unfreezes)",
},
Validate: validateViaInput(dimFreezeInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)

View File

@@ -88,6 +88,7 @@ var TablePut = common.Shortcut{
return tablePutWrite(ctx, runtime, token, payload, styles)
},
Tips: []string{
`Example: lark-cli sheets +table-put --url <URL> --sheets '{"sheets":[{"name":"S1","columns":["City","Rev"],"dtypes":{"Rev":"float64"},"data":[["SH",1234.5]]}]}'`,
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
@@ -241,6 +242,11 @@ func decoderExpectEOF(dec *json.Decoder) error {
return nil
}
// tablePutSheetsSkeleton is the one-line --sheets shape inlined on a decode
// error, so the retry needs no --print-schema round trip. Field vocabulary
// mirrors tableSheetIn.
const tablePutSheetsSkeleton = `{"sheets":[{"name":"Sheet1","columns":["City","Revenue"],"dtypes":{"Revenue":"float64"},"data":[["SH",123.4],["BJ",56.7]],"start_cell":"A1"}]}`
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
// validated payload. UseNumber keeps numeric cells as json.Number so large
// integers (order IDs, etc.) survive without precision loss or scientific
@@ -259,7 +265,19 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
Sheets []tableSheetIn `json:"sheets"`
}
if err := dec.Decode(&wire); err != nil {
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
// Eval traces show two distinct decode failures that each burned
// retries: a field with the wrong JSON kind (columns as objects,
// dtypes as an array) — fixed by seeing the expected shape once —
// and shell-mangled JSON, fixed by moving the payload to stdin/@file.
verr := common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
var ute *json.UnmarshalTypeError
if errors.As(err, &ute) {
return nil, verr.WithHint(
"expected shape: %s (columns is a flat string array; dtypes/formats are column-name-keyed maps; data is row-major)",
tablePutSheetsSkeleton)
}
return nil, verr.WithHint(
"if the payload contains formulas / quotes / commas, pass it via stdin (`--sheets - < file`) or a relative @file (`--sheets @./payload.json`)")
}
// Reject trailing non-whitespace after the first JSON value: json.Decoder
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
@@ -1208,8 +1226,7 @@ var TableGet = common.Shortcut{
}
sheets = append(sheets, spec)
}
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
return nil
return emitReadResult(runtime, map[string]interface{}{"sheets": sheets})
},
Tips: []string{
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
@@ -1354,11 +1371,18 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
"value_render_option": "raw_value",
"cell_limit": unboundedReadLimit,
}
// --max-chars binds the char budget (default 500000); --output-path lifts it
// to unbounded. Without this the tool applied its own ~50000 default and
// silently dropped rows past it with no signal in the +table-get output.
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
sheetSelectorForToolInput(input, t.id, t.name)
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
if err != nil {
return nil, err
}
truncated := cellRangesTruncated(out)
grid := extractCellGrid(out)
if len(grid) == 0 {
return emptySpec(), nil
@@ -1433,9 +1457,38 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
if len(formats) > 0 {
spec["formats"] = formats
}
// The tool clipped the read at max_chars: rows past the cap are missing from
// data. Surface it so the caller doesn't mistake a partial read for the whole
// sheet — re-run with --output-path (unlimited) or a higher --max-chars.
if truncated {
spec["truncated"] = true
spec["truncation_warning"] = "Result truncated by max_chars; rows past the cap were not returned. Best: re-run with --output-path to dump the whole sheet in one lossless pass (no cap). Alternatively raise --max-chars, or continue-read the remaining rows by passing --range for them — but that needs --no-header and you must reattach the header row and reconcile per-chunk dtypes yourself (this chunk's types were inferred from the rows returned here)."
}
return spec, nil
}
// cellRangesTruncated reports whether a get_cell_ranges response was clipped by
// max_chars — either the top-level has_more flag or the first range's truncated
// flag. Used by +table-get, whose spec output otherwise drops both signals.
func cellRangesTruncated(out interface{}) bool {
m, ok := out.(map[string]interface{})
if !ok {
return false
}
if hm, ok := m["has_more"].(bool); ok && hm {
return true
}
ranges, _ := m["ranges"].([]interface{})
if len(ranges) > 0 {
if r0, ok := ranges[0].(map[string]interface{}); ok {
if t, ok := r0["truncated"].(bool); ok {
return t
}
}
}
return false
}
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
// or "" for an empty sheet.
//

View File

@@ -405,7 +405,11 @@ var SheetCopy = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+sheet-copy"),
Validate: validateViaInput(sheetCopyInput),
Tips: []string{
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
},
Validate: validateViaInput(sheetCopyInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -965,7 +969,11 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
if len(items) != 1 {
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
}
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
return payload, nil
}
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
@@ -988,21 +996,28 @@ func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*w
}
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
var probs []error
for i, item := range items {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
continue
}
if name != payload.Sheets[i].Name {
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
continue
}
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if err != nil {
return nil, err
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if len(itemProbs) > 0 {
probs = append(probs, itemProbs...)
continue
}
out.ByIndex[i] = style
out.ByName[name] = style
}
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
return out, nil
}
@@ -1030,182 +1045,268 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
return items, nil
}
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
// are validated even after one fails, and every issue is returned in the
// slice: eval traces show agents fixing --styles errors one round trip per
// error (border side, then row_sizes.type, then size…) because only the
// first was ever reported.
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, []error) {
payload := &workbookCreateStylePayload{}
var err error
var probs []error
if raw, ok := item["cell_styles"]; ok {
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
if err != nil {
return nil, err
}
var errsHere []error
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
probs = append(probs, errsHere...)
}
if raw, ok := item["row_sizes"]; ok {
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
if err != nil {
return nil, err
}
var errsHere []error
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
probs = append(probs, errsHere...)
}
if raw, ok := item["col_sizes"]; ok {
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
if err != nil {
return nil, err
}
var errsHere []error
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
probs = append(probs, errsHere...)
}
if raw, ok := item["cell_merges"]; ok {
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
if err != nil {
return nil, err
}
var errsHere []error
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
probs = append(probs, errsHere...)
}
if len(probs) > 0 {
return nil, probs
}
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)}
}
return payload, nil
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
// joinStyleValidationErrors folds the issues collected across one --styles
// parse into a single typed error that lists them all, so the caller can fix
// the whole payload in one retry instead of one error per round trip.
func joinStyleValidationErrors(probs []error) error {
switch len(probs) {
case 0:
return nil
case 1:
return probs[0]
}
const maxShown = 8
msgs := make([]string, 0, len(probs))
for _, e := range probs {
if p, ok := errs.ProblemOf(e); ok {
msgs = append(msgs, p.Message)
continue
}
msgs = append(msgs, e.Error())
}
suffix := ""
if len(msgs) > maxShown {
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
msgs = msgs[:maxShown]
}
return common.ValidationErrorf("--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix)
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
styleObj := make(map[string]interface{}, len(op)-1)
for k, v := range op {
if k == "range" {
continue
}
styleObj[k] = v
}
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
if len(style) == 0 {
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
}
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateCellStyleOp{}, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
}
styleObj := make(map[string]interface{}, len(op)-1)
for k, v := range op {
if k == "range" {
continue
}
styleObj[k] = v
}
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
if err != nil {
return workbookCreateCellStyleOp{}, err
}
if len(style) == 0 {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
}
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateMergeOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
}
mergeType = strings.TrimSpace(v)
}
switch mergeType {
case "all", "rows", "columns":
default:
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateMergeOp{}, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
}
mergeType = normalizeMergeType(strings.TrimSpace(v))
}
switch mergeType {
case "all", "rows", "columns":
default:
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
return workbookCreateMergeOp{}, err
}
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
}
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
// the caller's enum check to reject.
func normalizeMergeType(v string) string {
lower := strings.ToLower(v)
lower = strings.TrimPrefix(lower, "merge_")
switch lower {
case "all", "rows", "columns":
return lower
}
return v
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateResizeOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
}
typeHint := "pixel/standard"
if dimension == "row" {
typeHint = "pixel/standard/auto"
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType == "" {
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
}
if dimension == "column" && resizeType == "auto" {
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
}
size := 0
if raw, ok := op["size"]; ok {
n, ok := util.ToFloat64(raw)
if !ok || n <= 0 {
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
}
size = int(n)
}
if resizeType == "pixel" && size <= 0 {
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
}
if resizeType != "pixel" && size > 0 {
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
// resizeOpExample renders a complete valid op for the dimension, inlined on
// every type/size error: eval traces show the field errors chaining (type
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
// trip, because no error ever showed a whole valid op at once.
func resizeOpExample(dimension string) string {
if dimension == "column" {
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
}
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
}
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateResizeOp{}, err
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
}
typeHint := "pixel/standard"
if dimension == "row" {
typeHint = "pixel/standard/auto"
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType == "" {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type is required (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
}
if dimension == "column" && resizeType == "auto" {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
}
size := 0
if raw, ok := op["size"]; ok {
n, ok := util.ToFloat64(raw)
if !ok || n <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
}
size = int(n)
}
if resizeType == "pixel" && size <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
}
if resizeType != "pixel" && size > 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
}
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size"); err != nil {
return workbookCreateResizeOp{}, err
}
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
}
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
@@ -1259,6 +1360,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
if !ok {
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
}
expandBorderAllShorthand(m)
if err := validateWorkbookBorderStyles(m, path); err != nil {
return nil, err
}
@@ -1299,7 +1401,7 @@ func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
switch side {
case "top", "bottom", "left", "right":
default:
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
}
spec, ok := raw.(map[string]interface{})
if !ok {

View File

@@ -48,7 +48,11 @@ var CellsSet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set"),
Validate: validateViaInput(cellsSetInput),
Tips: []string{
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
},
Validate: validateViaInput(cellsSetInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -124,7 +128,11 @@ var CellsSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set-style"),
Validate: validateViaInput(cellsSetStyleInput),
Tips: []string{
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
},
Validate: validateViaInput(cellsSetStyleInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet read → file offload ───────────────────────────────────
//
// Shared plumbing for +cells-get / +csv-get / +table-get behind the
// --output-path flag: when a caller redirects a read to a file, the char cap
// should default to unlimited so the whole sheet lands on disk instead of being
// clipped by the stdout-oriented max_chars safety cap.
// readOutputPath returns the trimmed --output-path flag value ("" when unset).
func readOutputPath(runtime *common.RuntimeContext) string {
return strings.TrimSpace(runtime.Str("output-path"))
}
// maxCharsInput resolves the max_chars value to send to the underlying read
// tool. With --output-path set the cap is lifted (unbounded sentinel) so the
// full result is written to the file; otherwise the --max-chars value binds.
// The second return is false when nothing should be sent (max-chars <= 0), in
// which case the tool's own default applies. Note the tool truncates at ~50000
// even when max_chars is omitted, so callers that want an explicit cap should
// pass a positive default.
func maxCharsInput(runtime *common.RuntimeContext) (int, bool) {
if readOutputPath(runtime) != "" {
return unboundedReadLimit, true
}
if n := runtime.Int("max-chars"); n > 0 {
return n, true
}
return 0, false
}
// emitReadResult delivers a read shortcut's result. When --output-path is set it
// writes the data payload to that path as pretty JSON and prints a small
// confirmation envelope to stdout (path + byte count); otherwise it prints the
// full result envelope to stdout as usual.
func emitReadResult(runtime *common.RuntimeContext, out interface{}) error {
path := readOutputPath(runtime)
if path == "" {
runtime.Out(out, nil)
return nil
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(b)); err != nil {
return err
}
resolved, err := runtime.FileIO().ResolvePath(path)
if err != nil {
resolved = path
}
runtime.Out(map[string]interface{}{
"output_path": resolved,
"bytes_written": len(b),
}, nil)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
@@ -83,7 +84,7 @@ func callTool(
code, _ := util.ToFloat64(envelope["code"])
if code != 0 {
msg, _ := envelope["msg"].(string)
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), flattenToolErrorMsg(msg)).
WithCode(int(code))
}
data, _ := envelope["data"].(map[string]interface{})
@@ -100,6 +101,47 @@ func callTool(
return out, nil
}
// flattenToolErrorMsg unwraps the nested-escaped-JSON error payload some
// sheet-ai tools put in msg — batch_update in particular wraps its result as
// {"error":"{\"message\":\"batch_update: N succeeded, M failed\",
// \"failures\":[…]}","errorType":…,"data":{…}} — into one readable line
// naming each failed operation. Eval traces show agents (and even the eval
// aggregator) failing to extract the real cause from the double-escaped
// form. Anything that doesn't match the nested shape passes through
// untouched.
func flattenToolErrorMsg(msg string) string {
trimmed := strings.TrimSpace(msg)
if !strings.HasPrefix(trimmed, "{") {
return msg
}
var outer struct {
Error string `json:"error"`
}
if json.Unmarshal([]byte(trimmed), &outer) != nil || strings.TrimSpace(outer.Error) == "" {
return msg
}
inner := strings.TrimSpace(outer.Error)
var detail struct {
Message string `json:"message"`
Failures []struct {
Index int `json:"index"`
ToolName string `json:"tool_name"`
Error string `json:"error"`
} `json:"failures"`
}
if strings.HasPrefix(inner, "{") && json.Unmarshal([]byte(inner), &detail) == nil && detail.Message != "" {
if len(detail.Failures) == 0 {
return detail.Message
}
parts := make([]string, 0, len(detail.Failures))
for _, f := range detail.Failures {
parts = append(parts, fmt.Sprintf("operations[%d] (%s): %s", f.Index, f.ToolName, f.Error))
}
return detail.Message + " — " + strings.Join(parts, "; ")
}
return inner
}
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
// The wire-format body (with input serialized to a JSON string) is preserved
// for fidelity, and a decoded tool_input map is surfaced alongside so humans

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestFlattenToolErrorMsg pins the unwrap of batch_update's double-escaped
// error payload (the exact shape from eval V2U038/V2U013 traces) and the
// pass-through of everything else.
func TestFlattenToolErrorMsg(t *testing.T) {
t.Parallel()
t.Run("batch failures flatten to one line", func(t *testing.T) {
t.Parallel()
msg := `{"error":"{\"message\":\"batch_update: 0 succeeded, 1 failed\",\"succeeded\":0,\"failed\":1,\"failures\":[{\"index\":0,\"tool_name\":\"manage_chart_object\",\"error\":\"invalid snapshot.data.dim1.serie.index: 0, must be >= 1 (index is 1-based)\",\"errorType\":\"param_error\"}]}","errorType":"param_error","data":{"total":2,"succeeded":0,"failed":1}}`
got := flattenToolErrorMsg(msg)
for _, want := range []string{
"batch_update: 0 succeeded, 1 failed",
"operations[0] (manage_chart_object): invalid snapshot.data.dim1.serie.index",
} {
if !strings.Contains(got, want) {
t.Errorf("flattened msg should contain %q, got %q", want, got)
}
}
if strings.Contains(got, `\"`) {
t.Errorf("flattened msg must not carry escaped JSON, got %q", got)
}
})
t.Run("plain-string inner error unwraps", func(t *testing.T) {
t.Parallel()
got := flattenToolErrorMsg(`{"error":"sheet \"s\" not found","errorType":"param_error"}`)
if got != `sheet "s" not found` {
t.Errorf("got %q", got)
}
})
t.Run("non-JSON msg passes through", func(t *testing.T) {
t.Parallel()
msg := `cell at row 0, col 1 is inside a merged region (top-left: A1)`
if got := flattenToolErrorMsg(msg); got != msg {
t.Errorf("got %q", got)
}
})
t.Run("JSON without error field passes through", func(t *testing.T) {
t.Parallel()
msg := `{"detail":"x"}`
if got := flattenToolErrorMsg(msg); got != msg {
t.Errorf("got %q", got)
}
})
}

View File

@@ -35,6 +35,11 @@ func Shortcuts() []common.Shortcut {
if hasFlag(all[i].Flags, "spreadsheet-token") {
all[i].PostMount = withTokenAlias(all[i].PostMount)
}
// +chart-create grows --print-example (minimal per-type --properties
// templates) — the biggest --print-schema consumer in eval traces.
if all[i].Command == "+chart-create" {
all[i].PostMount = withChartPrintExample(all[i].PostMount)
}
// Sheets-scoped flag ergonomics (unknown-flag hints with the valid
// flags inlined, enum vocabulary normalization) ride the same
// PostMount composition, so no other domain's behavior shifts.
@@ -146,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

@@ -0,0 +1,250 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestTablePut_StylesErrorsAggregate pins the one-retry contract for
// --styles: every issue across sections and ops is reported in a single
// error (eval V2U032 burned three round trips fixing a border side, then
// row_sizes.type, then size — each surfaced only after the previous fix).
func TestTablePut_StylesErrorsAggregate(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"s",
"cell_styles":[{"range":"A1:A1","border_styles":{"horizontal":{"style":"solid"}}}],
"row_sizes":[{"range":"1:1","type":"custom"}],
"col_sizes":[{"range":"A:A","type":"pixel"}]}]}`,
"--dry-run",
})
ve := requireValidation(t, err, "--styles has 3 issues")
for _, want := range []string{
"border_styles.horizontal is not a valid side",
`row_sizes[0].type "custom" is invalid`,
"col_sizes[0].type pixel requires size",
} {
if !strings.Contains(ve.Message, want) {
t.Errorf("aggregated message should contain %q, got %q", want, ve.Message)
}
}
// D2: each type/size error inlines a complete valid op.
if !strings.Contains(ve.Message, `{"range":"2:10","type":"pixel","size":32}`) {
t.Errorf("row_sizes error should inline a full valid example, got %q", ve.Message)
}
if !strings.Contains(ve.Message, `{"range":"A:C","type":"pixel","size":120}`) {
t.Errorf("col_sizes error should inline a full valid example, got %q", ve.Message)
}
}
// TestTablePut_StylesBorderAllExpands verifies the "all" shorthand is
// rewritten to four explicit sides instead of being rejected (or worse,
// passed through for the server to reject, as happened on the typed-cells
// path in eval V2U013/V2U021).
func TestTablePut_StylesBorderAllExpands(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"s","cell_styles":[{"range":"A1:A1","border_styles":{"all":{"style":"solid","weight":"thin"}}}]}]}`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
}
// table-put's dry-run body carries the tool input as an escaped JSON
// string, so match the escaped key form.
for _, side := range []string{`\"top\"`, `\"bottom\"`, `\"left\"`, `\"right\"`} {
if !strings.Contains(stdout, side) {
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
}
}
if strings.Contains(stdout, `\"all\"`) {
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
}
}
// TestCellsSet_BorderAllAndMisNestedBorder covers the typed --cells path:
// the "all" shorthand expands CLI-side, and border_styles mis-nested inside
// cell_styles is intercepted with a move-it prescription instead of a
// server-side 900015206.
func TestCellsSet_BorderAllAndMisNestedBorder(t *testing.T) {
t.Parallel()
t.Run("border all expands", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `[[{"value":"x","border_styles":{"all":{"style":"solid"}}}]]`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand and pass, got: %v", err)
}
if strings.Contains(stdout, `"all"`) || !strings.Contains(stdout, `"top"`) {
t.Errorf("dry-run body should carry expanded sides, got %q", stdout)
}
})
t.Run("mis-nested border_styles intercepted", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `[[{"value":"x","cell_styles":{"font_weight":"bold","border_styles":{"top":{"style":"solid"}}}}]]`,
"--dry-run",
})
ve := requireValidation(t, err, "cell_styles.border_styles is not valid")
if !strings.Contains(ve.Message, "sibling of cell_styles") {
t.Errorf("message should prescribe moving it up one level, got %q", ve.Message)
}
})
}
// TestCellsSetStyle_BorderAllExpands covers the --border-styles flag path
// (+cells-set-style / +cells-batch-set-style go through borderStylesFromFlag,
// not the typed --cells or --styles walkers): the "all" shorthand must expand
// CLI-side here too, or the backend rejects {"all":…}.
func TestCellsSetStyle_BorderAllExpands(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--border-styles", `{"all":{"style":"solid","weight":"thin"}}`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
}
for _, side := range []string{`"top"`, `"bottom"`, `"left"`, `"right"`} {
if !strings.Contains(stdout, side) {
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
}
}
if strings.Contains(stdout, `"all"`) {
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
}
}
// TestCellsMerge_RawAPIVocabularyNormalizes pins MERGE_ALL → all (the raw
// OpenAPI enum agents copy from Lark API docs) via the enum alias table.
func TestCellsMerge_RawAPIVocabularyNormalizes(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-merge")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:B2",
"--merge-type", "MERGE_ALL",
"--dry-run",
})
if err != nil {
t.Fatalf("MERGE_ALL should normalize to all and pass, got: %v", err)
}
if !strings.Contains(stdout, `"all"`) {
t.Errorf("dry-run body should carry the normalized merge type, got %q", stdout)
}
}
// TestCellsSetStyle_WordWrapBooleanNormalizes pins --word-wrap true →
// auto-wrap (eval V2U029).
func TestCellsSetStyle_WordWrapBooleanNormalizes(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--word-wrap", "true",
"--dry-run",
})
if err != nil {
t.Fatalf("--word-wrap true should normalize to auto-wrap, got: %v", err)
}
if !strings.Contains(stdout, "auto-wrap") {
t.Errorf("dry-run body should carry auto-wrap, got %q", stdout)
}
}
// TestUnderscoreFlagFormsParse pins the wire-vocabulary underscore rewrite:
// --sheet_name / --border_styles parse as their hyphen forms (agents copy
// field names out of JSON payloads where underscores are canonical).
func TestUnderscoreFlagFormsParse(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet_name", "s",
"--range", "A1:A1",
"--font_weight", "bold",
"--dry-run",
})
if err != nil {
t.Fatalf("underscore flag forms should parse as hyphen forms, got: %v", err)
}
if !strings.Contains(stdout, "bold") {
t.Errorf("dry-run body should carry the style, got %q", stdout)
}
}
// TestPrintFlagSchema_UnderscoreFlagName pins --flag-name border_styles
// resolving the border-styles schema (eval V2U013 burned a retry on this).
func TestPrintFlagSchema_UnderscoreFlagName(t *testing.T) {
t.Parallel()
print := printFlagSchemaFor("+cells-set-style")
out, err := print("border_styles")
if err != nil {
t.Fatalf("underscore flag-name should resolve the hyphen schema, got: %v", err)
}
if len(out) == 0 {
t.Fatal("expected schema output")
}
}
// TestPrintFlagSchema_DottedPathSlices pins the schema sub-path slicing
// contract on the real embedded chart schema: a dotted --flag-name returns
// just that subtree, and a path miss lists the keys actually available.
func TestPrintFlagSchema_DottedPathSlices(t *testing.T) {
t.Parallel()
print := printFlagSchemaFor("+chart-create")
t.Run("slices a nested subtree", func(t *testing.T) {
t.Parallel()
out, err := print("properties.snapshot.plotArea.axes")
if err != nil {
t.Fatalf("dotted path should slice, got: %v", err)
}
full, err2 := print("properties")
if err2 != nil {
t.Fatalf("full dump: %v", err2)
}
if len(out) == 0 || len(out) >= len(full) {
t.Errorf("slice should be non-empty and smaller than the full schema (%d vs %d bytes)", len(out), len(full))
}
})
t.Run("path miss lists available keys", func(t *testing.T) {
t.Parallel()
_, err := print("properties.snapshot.nosuchkey")
if err == nil {
t.Fatal("expected error for unknown path segment")
}
if !strings.Contains(err.Error(), "available keys:") {
t.Errorf("error should list available keys, got %v", err)
}
})
}

View File

@@ -15,13 +15,7 @@ metadata:
## 术语约定
下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念:
| 标准用语 | 同义 / 口语(均指同一对象) | 说明 |
| --- | --- | --- |
| 工作表sheet | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 |
| 电子表格spreadsheet | 工作簿、表格 | 顶层容器;由 `--url``--spreadsheet-token` 定位 |
| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image``--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id |
同一对象的交替说法,按此映射解析用户口语:**工作表sheet**= 子表 / tab / 标签页(`sheet_id` 是稳定标识);**电子表格spreadsheet**= 工作簿 / 表格(顶层容器,由 `--url``--spreadsheet-token` 定位);**reference_id** = 表内对象的稳定标识,即各对象主键 flag 接受的值(与 `--image-uri` 图片上传句柄不是一回事)。
每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼):
@@ -34,30 +28,30 @@ metadata:
## 飞书表格编辑准则(动手前必守,所有编辑类任务一律生效)
下列准则横切所有飞书表格任务,**动手前先过一遍**——即使你是被索引直接路由进某个工具参考也一律生效。每条只给一句话纲要,展开与边界见括注的 reference。
下列准则横切所有任务,**动手前先过一遍**——被索引直接路由进某个工具参考也一律生效展开与边界见括注的 reference。
1. **最小改动**:除任务要改的单元格 / 列外原表其它单元格、行列结构、Sheet 名、合并区、格式 1:1 保持;中间结果放原数据右侧或新建空白 Sheet**禁止删 / 改名 / 隐藏 / 移动已存在 Sheet**;改写类任务精确圈定行列,不该转的原值 1:1 保留。
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,写完用 `+csv-get` / `+cells-get` / `+<对象>-list` 回读确认实际生效——**写操作返回 `ok` 只代表请求被接受、不代表结果符合预期**;写公式后查错误码、筛选 / 排序后核对前几行、删除 / 清空后确认已空。禁止只在文本里声称"已完成"。
3. **读全再写**:批量填充 / 补齐 / 修正类任务先确认真实数据末行再写,只探前 N 行会漏写表尾(确定末行流程见 `lark-sheets-read-data`)。
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找)一律写公式而非静态值**凡可由表内其它单元格推导的派生值默认用公式,即使用户没说"联动 / 自动更新"**;写任何飞书公式前先读 `lark-sheets-formula-translation`而且**只要公式真实写入表格,收尾默认就要继续跑 `lark-sheets-formula-verify``+formula-verify`直到 `status='success'`**。
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 提取 / 查找)一律写公式而非静态值——**凡可由表内其它单元格推导的派生值默认用公式,即使用户没说"联动"**;写公式前先读 `lark-sheets-formula-translation`**公式落表后收尾必跑 `+formula-verify` 直到 `status='success'`**。
5. **续写 / 扩展继承样式**:续写、补齐、复制区块、新增行列时禁止只读值只写值,必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承(清单见 `lark-sheets-write-cells`,四边框最易漏)。
6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`(语义见 `lark-sheets-batch-update`)。
6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`high-risk-write**调用必带 `--yes`**语义见 `lark-sheets-batch-update`)。
7. **分组汇总用透视表**"按 X 统计 Y / 分组汇总 / 各类数量金额"用 `+pivot-{create|update|delete}`,禁止用 SUMIF / 本地脚本拼一张假透视表。
8. **拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",逐点 `assert` 全过才交付(多维排序每维一点、多目标每目标一点、范围类核起 / 末 / 边界);只做第一个要点属违规。
9. **全量处理前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,先把预期条数硬编码再 `assert actual == expected`,禁止输出"已完成前 N 条,剩余继续"的半成品。
> 上述准则的实操展开——读取路径、原生工具优先级、脚本配合、易漏陷阱——见下方「执行要点」节;端到端工作流:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
> 端到端工作流:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证;实操展开见下方「执行要点」
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
把高频意图映射到**真实存在**的 shortcut / flagagent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag先对照本表避免一次必然失败的试错。完整 shortcut 见各工具参考。**选定命令后别急着写——先读「动手前读」列指向的 reference 再动手**命令名对得上不代表用法对,写入 / 清除 / 透视类尤其容易漏掉 reference 里的防错、类型与样式继承规则
把高频意图映射到**真实存在**的 shortcut / flagagent 常从 Excel / Google Sheets / OpenAPI 误迁移命令名。**选定命令后先读「动手前读」列指向的 reference 再动手**——命令名对得上不代表用法对。
| 你要做的事 | ✅ 正确写法 | 动手前读 | ❌ 不存在(会被 cobra 拒) |
| --- | --- | --- | --- |
| 读数据(纯值 / CSV | `+csv-get`(范围用 `--range` | `lark-sheets-read-data` | `+get-range``+range-get``+cells-read` |
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `lark-sheets-read-data` | `+get-cell``+cell-get``--with-styles``--with-merges``--include-merged-cells` |
| 写纯文本值(整块 CSV 平铺;列里**没有**需字面保真的数值 / 日期标签 / 编号——点分日期 `12.10`、编号 `001` 会被 csv-put 数值化,不算纯文本 | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格也接受 `--range` 别名,区间自动取左上角 | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10``12.1``001``1`,尾零/前导零丢失),改用 `+table-put` 声明 `dtypes:object` |
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数等**本质是量值**的数据——不看当下要不要排序 / 求和,量值一律走这里) | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理;要同时美化加 `--styles` 一步带样式(区域底色 / 边框 / 列宽 / 行高 / 合并不必事后再刷payload 里不存在的 sheet 名会自动建子表,详见 write-cells | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`落成文本、丢计算能力;常见借口见下方 ⚠️) |
| 写纯文本值(整块 CSV 平铺;列里没有需字面保真的编号 / 点分日期 | `+csv-put`(定位用 `--start-cell` 左上角锚点格也接受 `--range` 别名) | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10``12.1``001``1`),改用 `+table-put` 声明 `dtypes:object` |
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期等**量值**——不看当下要不要排序求和,量值一律走这里) | `+table-put --sheets '{"sheets":[{"name":…,"columns":[…],"dtypes":{…},"formats":{…},"data":[[…]]}]}'`(不存在的 sheet 名自动建子表;同时美化加 `--styles` 一步带样式,详见 write-cells | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(落成文本、丢计算能力见下方 ⚠️) |
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`date / number 不丢;`--styles` 同样可在建表同一步带全套样式,详见 workbook | `lark-sheets-workbook` | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(定位用 `--range`;批注 / 图片 / 富文本只能用它,公式也可;**公式落表后继续 `+formula-verify` 收尾** | `lark-sheets-write-cells` | — |
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | `lark-sheets-write-cells` | — |
@@ -68,37 +62,50 @@ metadata:
| 复核某次AI编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20 | `lark-sheets-changeset` | — |
| 取当前文档 revision版本号 | `+revision-get` | `lark-sheets-workbook` | — |
| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — |
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable` | `lark-sheets-workbook` | `drive +import`导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import`)、要把文件并入某个**已有在线工作簿**(给它加子表)却用它——import 只会新建独立表,加子表`+sheet-copy` / `+sheet-create` |
| 参考某个**已有在线表**、把多个本地文件 / 数据各作为一张子表**追加**进去(不另起独立表) | 先 `+workbook-info` 拿模板子表 `sheet_id``+sheet-copy` 逐张复制模板子表(公式 / 合并 / 分组底色 / 列宽 / 条件格式全继承)再 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` 建空子表 + `+table-put --sheets/--styles` 写入 | `lark-sheets-workbook` | 把文件 `+workbook-import` / `+workbook-create` 另起一张**独立新表**(目标是并入已有工作簿时就跑偏了;这两条只产新表、不接受已有表定位) |
| 清除内容 / 格式 | `+cells-clear`范围维度用 `--scope`,取值 content / formats / all | `lark-sheets-range-operations` | `--type` |
| 批量清除多区域 | `+cells-batch-clear``--scope` | `lark-sheets-batch-update` | `--target` |
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(仅要导成多维表格 bitable 时才用 `drive +import --type bitable` | `lark-sheets-workbook` | `drive +import`绕路)、本地读 .xlsx 再 `+workbook-create` 重灌(多此一举)、想并入**已有工作簿**却用它import 只会另起新表,加子表走 `+sheet-copy` / `+sheet-create` |
| 参考某个**已有在线表**、把多数据各作为一张子表**追加**进去 | 先 `+workbook-info``+sheet-copy` 复制模板子表(公式 / 合并 / 底色 / 列宽全继承)再 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` + `+table-put --sheets/--styles` | `lark-sheets-workbook` | `+workbook-import` / `+workbook-create` 另起独立新表(这两条只产新表、不接受已有表定位) |
| 清除内容 / 格式 | `+cells-clear --yes`(需确认;范围维度用 `--scope`,取值 content / formats / all | `lark-sheets-range-operations` | `--type` |
| 批量清除多区域 | `+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` | `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` |
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**本次操作只要**涉及样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 汇总行 / 配色 / 列宽行高),动手前先读 `lark-sheets-visual-standards`只要**要写飞书公式**,动手前先读 `lark-sheets-formula-translation`(飞书函数与 Excel 有差异,凭直觉迁移易错),**写完后再读 `lark-sheets-formula-verify` 并执行 `+formula-verify` 收尾**。哪怕主任务是"建表 / 展开数据 / 录入",只要动作里含美化或写公式就适用——别因"这不算专门的美化 / 公式任务"而跳过
> ⚠️ **两种图片别选错**:图**绑定某条记录、随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`只是自由摆放的装饰logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 比率 / 计数 / 日期等**本质是量值**的数据 → 一律数值写入常规二维表用 `+table-put``dtypes` 声明类型 + `formats` 设展示格式),版式装不下(多级 / 合并表头的宽表 leaderboard 等)改用 `+cells-set` 传数字(百分比传小数 `0.4`+ `number_format`,照样显示 `40%` 且数值无损。只有编号 / 身份证 / 单据号这类**本质是标识符**、要字面保真的才用 `+csv-put` 平铺。**几个常见借口都不成立**——"只是 leaderboard / 报表展示不用算""版式复杂""样式以后再刷、先铺文本"都不是把百分比写成 `"40%"` 字符串灌 `+csv-put` 的理由(展示不改变它是数值;类型不能后补,落成文本就回不来)。判据与操作展开见 `lark-sheets-write-cells`「数字还是文本」。
> ⚠️ **要新建子表 / 整表美化 → 别默认「`+csv-put` 写值再事后刷样式」**`+table-put` / `+workbook-create` 的 `--styles` 在写数据**同一步**带全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并),且 `+table-put` 的 payload 里 sheet 名不在工作簿中会自动建子表——**纯文本表要新建子表 + 美化时同样走这里**`--styles` 与列是否 typed 无关),比「`+csv-put` 写值 + 多次 `+cells-batch-set-style` / `+*-resize` 刷样式少好几次调用(冻结行列等 sheet 级属性仍需 `+dim-freeze` 单独一步)。
> ⚠️ **定位 flag**`+cells-get` / `+cells-set` / `+csv-get` 用 `--range``+csv-put` 规范用 `--start-cell`单个左上角锚点格),也接受 `--range` 别名区间自动取左上角),二者择一即可
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`**没有** `--with-styles` 这类 flag**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**动作里**含样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 配色 / 列宽行高)先读 `lark-sheets-visual-standards`**要写飞书公式**先读 `lark-sheets-formula-translation`,写完跑 `+formula-verify` 收尾(见 `lark-sheets-formula-verify`)。主任务是建表 / 录入也一样适用
> ⚠️ **两种图片别选错**:图**绑定某条记录、随行**(凭证 / 证件照 / 每行配图)→ `+cells-set-image`自由摆放的装饰logo / 水印 / 封面)→ `+float-image-create`。别因「浮动图更熟」默认选浮动图。
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 日期 / 计数等**量值**一律数值写入——常规二维表用 `+table-put``dtypes` + `formats`),宽表 / 合并表头版式用 `+cells-set` 传数字(百分比传小数 `0.4`+ `number_format`。只有编号 / 身份证等**标识符**才 `+csv-put` 平铺。"只是展示不用算 / 样式以后再刷"不构成把量值写成字符串的理由——类型不能后补。判据见 `lark-sheets-write-cells`「数字还是文本」。
> ⚠️ **要新建子表 / 整表美化 → 别「`+csv-put` 写值再事后刷样式」**`+table-put` / `+workbook-create` 的 `--styles` 在写数据**同一步**带全套样式(底色 / 边框 / 列宽行高 / 合并payload 里不存在的 sheet 名自动建子表,纯文本表同样适用;比事后多次刷样式少好几次调用(冻结行列仍需 `+dim-freeze` 单独一步)。
> ⚠️ **定位 flag**`+cells-get` / `+cells-set` / `+csv-get` 用 `--range``+csv-put` 用 `--start-cell`(也接受 `--range` 别名区间取左上角)。
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`(无 `--with-styles` 这类 flag**看合并单元格**用 `+sheet-info` 的 `merged_cells`。
💡 **高频写命令签名(照抄改参即可;各命令 `--help` 的 Tips 段有同款示例)**
```bash
lark-cli sheets +cells-set --url <U> --sheet-name S1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]' # --cells 恒为二维数组 [[…]],单格也是 [[{…}]]
lark-cli sheets +cells-set-style --url <U> --sheet-name S1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center
lark-cli sheets +cells-batch-set-style --url <U> --ranges '["S1!A1:B2","汇总!C1:C9"]' --font-weight bold # range 带表名前缀,无 sheet 定位 flag
lark-cli sheets +batch-update --url <U> --yes --operations - <<'JSON'
[{"shortcut":"+cells-set","input":{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}}]
JSON
lark-cli sheets +dim-freeze --url <U> --sheet-name S1 --dimension row --count 2
lark-cli sheets +dim-insert --url <U> --sheet-name S1 --position 3 --count 2 --inherit-style before # 行/列由 --position 决定:数字=行、字母=列,无 --dimension
lark-cli sheets +cols-resize --url <U> --sheet-name S1 --range A:C --width 120 # 像素;分列不同宽用 --widths '{"A":80,"C:E":120}'
lark-cli sheets +sheet-copy --url <U> --sheet-name 源表名 --title 副本名 # --sheet-name=源表、--title=新表名
```
## 执行要点(读取 / 原生工具 / 陷阱)
准则的实操展开。端到端工作流:了解结构 → 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
### 读取:按需求选路径(细则见 `lark-sheets-read-data`
| 用户需求 | 读取路径 |
|---|---|
| "完善 / 补齐 / 填空 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行,不以用户选区为准 |
| "查一下 / 看看 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
| "完善 / 补齐 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行) |
| "查一下 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
| 需要公式 / 样式 / 批注 | `+cells-get` |
| 续写 / 扩展已有内容 | `+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见准则 5 |
> "补齐 / 填空"类用只读路径探 10 行就写会漏写表尾——写入前先按 `lark-sheets-read-data` 确认真实数据末行(准则 3
> "补齐 / 填空"类只探前 10 行就写会漏写表尾——先按 `lark-sheets-read-data` 确认真实数据末行(准则 3
### 计算:原生工具优先,代码兜底(强化准则 7
@@ -122,16 +129,16 @@ metadata:
### 易漏陷阱
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框,新行回落默认高度截断长文本;插行填长文本前读相邻行 `row_height`,用 `+batch-update``+rows-resize` 补齐。
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首末各 5 行`#VALUE!` / `#REF!` / `#DIV/0!`,然后继续`+formula-verify` `status='success'`;同一方案试错上限 3 次。
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框;插行填长文本前读相邻行 `row_height`,用 `+batch-update``+rows-resize` 补齐。
- **公式容错**:日期 / 查找 / 转换公式用 `IFERROR` 包裹;写完首末各 5 行错误码,再`+formula-verify``status='success'`;同一方案试错上限 3 次。
- **循环引用**:聚合公式引用范围不能含目标 cell 自身或其传递依赖。
- **隐藏行列**`+csv-get` 默认含隐藏行列;`--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前`+workbook-info` 掌握全局。
- **NLP 任务分批**:语义理解 / 翻译 / 改写 / 分类等用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量大必须分批(通常 30 行 / 批),每批处理完即时写回,单批生成通常 ≤ 300 行,多批用 `+batch-update`
- **隐藏行列**`+csv-get` 默认含隐藏行列;`--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,先 `+workbook-info` 掌握全局。
- **NLP 任务分批**:语义理解 / 翻译 / 打标用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量分批( 30 行 / 批)即时写回,多批用 `+batch-update`
## References
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的样式公式规则,不含具体 shortcut它们规定了"怎么做对"再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
reference 分两组:先读**通用方法与规范**(横切所有任务的样式 / 公式规则,再按操作对象进入**工具参考**查具体 shortcut。编辑类任务务必先过通用方法与规范连同上方「飞书表格编辑准则」对所有工具参考一律生效。
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut
@@ -164,42 +171,22 @@ metadata:
## 公共 flag 速查
各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如
- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」`--dry-run`
- `_公共URL/token无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token常见于 `+batch-update` 等不强制 sheet 定位的 shortcut
徽章里只列名字。type / 必填 / 描述都在本段统一声明:
各 reference 的 shortcut 标题下用一行徽章标注支持的公共 / 系统 flag(如 `_公共四件套 · 系统:--dry-run_``_公共URL/token无 sheet 定位…_` 表示只接 URL/token。type / 必填 / 描述在本段统一声明
### 公共 flag定位资源
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR**每组都必须给且只能给一个**XOR = 二选一必填,不是"可选"
1. **spreadsheet 定位(必填)**`--url` `--spreadsheet-token` 二选一**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突
- **`--url` 解析 `/sheets/``/spreadsheets/``/wiki/` 三种链接**(从路径里抽出 token也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token
- **`/wiki/` 知识库链接可直接传 `--url`**:会自动定位到链接背后的电子表格;若该链接背后不是电子表格(而是文档 / 多维表格等),则报错
- **例外**`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--values` / `--styles` / `--sheets``+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`
2. **sheet 定位(公共四件套 shortcut 必填)**`--sheet-id``--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id或上下文之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id``--sheet-name`,否则照样报上面的错。
- ⚠️ **A1 reference 含 `!`**`--source` / `--range` / `--ranges`**:整段用单引号包裹**,如 `--range 'Sheet1!A1:B2'`——单引号能挡住 bash 的 history expansion`!` 被拦成 `event not found`;双引号挡不住;别改用 `set +H`,原因见下方「复合 JSON / 大入参」。sheet 名含特殊字符(`-` / 空格 / 非 ASCII需在内部按 A1 标准再包一层单引号时,用 `'\''` 转义保持外层单引号,如 `--source ''\''Sales-2025'\''!A1:D100'`
- **例外**:徽章标为 `_公共URL/token无 sheet 定位…_` 的 shortcut`+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create``--target-sheet-id` / `--target-sheet-name`XOR可都不传落点细节见 `lark-sheets-pivot-table`)。
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--url` | string | 二选一必填(与 `--spreadsheet-token` | spreadsheet 或 wiki URL |
| `--spreadsheet-token` | string | 二选一必填(与 `--url` | spreadsheet token |
| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut | 工作表 reference_id |
| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut | 工作表名称 |
**统一调用范式**(公共四件套 shortcut 的所有示例都遵循此形状,两组定位缺一不可):
1. **spreadsheet 定位(必填)**`--url`(解析 `/sheets/``/spreadsheets/``/wiki/` 三种链接wiki 链接自动定位背后的电子表格)`--spreadsheet-token`(裸 token二选一**例外**`+workbook-create` / `+workbook-import` 产出**还不存在**的表,不接受任何定位 flag
2. **sheet 定位(公共四件套 shortcut 必填)**`--sheet-id``--sheet-name` 二选一
- ⚠️ **不确定 sheet 名时禁止猜 `Sheet1`**:除非对话或上下文已出现具体值,第一步先 `+workbook-info``sheets[].sheet_id/title` 再选——中文表的子表常叫"数据"/"工作表 1"/业务名,猜名大概率撞 `sheet not found`
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:仍必须传 `--sheet-id` / `--sheet-name`
- ⚠️ **A1 引用含 `!` 时整段用单引号包裹**`--range 'Sheet1!A1:B2'`,挡 bash history expansion别用 `set +H`sh/dash 下非法。sheet 名含 `-`/空格需内层再包单引号时用 `'\''` 转义:`--source ''\''Sales-2025'\''!A1:D100'`
- **例外**:徽章标 `_公共URL/token无 sheet 定位…_` 的 shortcut`+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)不接受 sheet 定位。`+pivot-create``--target-sheet-id/name`XOR可都不传
```bash
lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给)
# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填)
# 例lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。
# 统一调用范式:两组定位缺一不可(占位符别原样填;表名先 +workbook-info 查)
lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
```
### 系统 flag
@@ -208,27 +195,27 @@ lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
| --- | --- | --- | --- |
| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 |
| `--yes` | bool | 是(仅 `high-risk-write` | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 |
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。 `--flag-name <name>` 搭配指定查哪个 flag省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 |
| `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名不带 `--` 前缀,如 `cells` / `properties` / `operations`。 |
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起调用、不需要其它 required flag。搭配 `--flag-name` 指定查哪个 flag省略时列出该 shortcut 可查询的 flag。仅对含复合 JSON flag 的 shortcut 有效。 |
| `--flag-name` | string | 否 | 配合 `--print-schema`flag 名不带 `--` 前缀`cells` / `properties`)。**支持点分路径切片**`--flag-name properties.snapshot.plotArea.axes` 只打印该子树,大 schemachart 的 properties 约 1700 行)按需取,别整篇翻页。 |
**Agent 使用提示**:写复合 JSON flag`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets <shortcut> --print-schema --flag-name <name>` 把完整 JSON Schema 读出来再构造 payload比靠 reference 的速查表更精确也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema``## Examples` 的真实示例
> ⚠️ **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。图表任务必须先读 `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 内容类型与输出约定(术语速记)
- flag 表里 JSON 类入参三类:**复合 JSON** = 深层嵌套对象(`--print-schema` 取完整结构**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema**非 JSON 文本** = 原样文本(如 CSV`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)
- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get``annotated_csv`;写操作不会自动回读,如需校验自行调用对应的 `+*-list` / `+*-get` / `+cells-get`
- JSON 类入参三类:**复合 JSON** = 深层嵌套对象(`--print-schema` 可查**简单 JSON** = 一二维标量数组;**非 JSON 文本** = 原样文本(如 CSV`--print-schema` 只对复合 JSON flag 有效。
- **envelope**:所有 shortcut 返回统一外层 `{ok, identity, data, ...}`;写操作不会自动回读,校验自行调用 `+*-list` / `+*-get` / `+cells-get`
## 复合 JSON / 大入参:优先 stdin
flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin`-`)传入,避免命令行超长与 shell 转义问题
推荐写法payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去:
大 payload`--operations` / `--cells` / `--sheets` / `--styles` / `--properties`…)、或含换行 / 引号 / `!` 等特殊字符时,优先 heredoc stdin`-`)传入,避免命令行超长与 shell 转义问题
```bash
# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录)
lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE"
lark-cli sheets +batch-update --url "..." --yes --operations - <<'JSON'
[{"shortcut":"+cells-set","input":{...}}]
JSON
```
**参数含特殊字符(`!` / 引号 / 空格 / 非 ASCII用单引号包裹该参数即可不要起手 `set +H` 之类的 shell 开关来防转义。** `set +H`(关 bash history expansion`sh` / `dash` 下是非法选项(`set: Illegal option -H`)、会让整条命令直接失败;而单引号挡得住 `!` 的 history expansion否则报 `event not found`),对 bash 与 `sh` / `dash` 一致安全。参数本身含单引号、或 payload 较大时,按上文走 stdin
**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin`--<flag> - < 文件`)。
- **stdin 每次调用只能给一个 flag**`+table-put` 同时传 `--sheets``--styles` 两个大 JSON 时,一个走 `-`、另一个走 `@./styles.json``@file` 只接受 cwd 下相对路径,**绝对路径会被拒**;正解是 stdin别 cd、别把临时文件写进用户项目目录
- **参数含特殊字符时用单引号包裹即可,不要 `set +H`**sh/dash 下非法直接报错);参数本身含单引号或 payload 大时走 stdin。

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。
## 需求→图表类型映射(创建前必查)
@@ -27,7 +39,9 @@
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
**`--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-schema --flag-name properties` 为准。
**`--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`
@@ -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`。

View File

@@ -44,6 +44,7 @@
- `+csv-get``+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;两者在处理返回数据之前都必须先读 `warning_message`(上游 schema 要求先读它再用其它字段,内含定位与截断续读提示),`+cells-get` 还要用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
- 要判断单元格内容是否被行高列宽挤到显示不全(排版检查、调整行高列宽前),给 `+cells-get``--include truncation`:会按字号 / 自动换行 / 行高列宽估算并返回被截断单元格的 `isRowTruncated` / `isColTruncated`(未返回视为未截断)。有额外计算开销,仅需要时才开
**常见配置错误(必须注意)**
- **全量读取导致上下文溢出**:不要对大表(数百行以上)直接用 `+csv-get``+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
@@ -99,8 +100,9 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet |
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation` |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000 has_more 分页 |
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个`truncation` 会额外按行高列宽 / 字号 / 自动换行估算每个单元格是否被截断显示,返回 `isRowTruncated` / `isColTruncated`(有额外计算开销,仅排版检查 / 调整行高列宽前才开)(可选值:`value` / `formula` / `style` / `comment` / `data_validation` / `truncation` |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆要整表无截断直接用 --output-path 落盘(自动放开为无限);仅当要让结果直接进上下文、又不落盘时才调小(如 25000 has_more 分页 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSONstdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
### `+dropdown-get`
@@ -118,7 +120,8 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000 has_more 分页 |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆要整表无截断直接用 --output-path 落盘(自动放开为无限);仅当要让结果直接进上下文、又不落盘时才调小(如 25000 has_more 分页 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSONstdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
@@ -131,6 +134,8 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| `--sheet-id` | string | optional | 只读该子表(按 id省略则读所有子表 |
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的完整 used range会跨过表中部的整行空行 / 整列空列,不会被截断) |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆。底层工具即使不传也有约 50000 的默认截断,故此处显式发送以放宽;要整表无截断请用 --output-path 落盘(自动放开为无限)。 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSONstdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
## Examples

View File

@@ -73,7 +73,7 @@
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与准则;选哪个 shortcut 见上方「使用场景」。
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text``type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas``--cells`
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text``type: "embed-image"` 嵌入单元格图片。**关键:`--cells` 恒为二维数组(行 × 格),单格也是 `[[{"value":…}]]`;且行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas``--cells`
> **单元格图片 vs 浮动图片(最易选错)**:图若**属于某条记录、要随那行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ **单元格图片**(本工具):用 `+cells-set-image`(最短)或 `+cells-set` 的 `rich_text` + `type: "embed-image"`。只是自由摆放的装饰logo / 水印 / 封面)→ 浮动图片,见 lark-sheets-float-image。别因「浮动图更好控制 / 更熟」默认选浮动图——它承载"对应某记录"的图会随增删行 / 排序错位。
@@ -511,6 +511,8 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
python export.py | lark-cli sheets +table-put --url "<表URL>" --sheets -
# 某 sheet 带 "mode":"append" 追加到已有数据末尾、默认不重复表头
lark-cli sheets +table-put --spreadsheet-token "<token>" --sheets @payload.json
# --sheets 与 --styles 都是大 JSON 时stdin 每次调用只能给一个 flag一个走 -、另一个走 @cwd 相对路径
lark-cli sheets +table-put --url "<表URL>" --sheets - --styles @styles.json < sheets.json
```
每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`

View File

@@ -605,6 +605,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
rotation = extract_numeric_attribute(attrs, "rotation") or 0
table_layouts: dict[str, dict[str, Any] | None] = {}
if kind == "table":
width, table_layouts["width"] = resolve_table_dimension(
@@ -622,6 +623,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
"y": y,
"width": width,
"height": height,
"rotation": rotation,
"order": len(elements),
}
if kind == "table":
@@ -963,16 +965,48 @@ def detect_whiteboard_external_overlaps(
return issues
def detect_table_out_of_canvas(
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
return bbox
rotation = element["rotation"]
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
rotation = 0
rotation %= 360
if math.isclose(rotation, 0, abs_tol=1e-9):
return bbox
radians = math.radians(rotation)
sine = abs(math.sin(radians))
cosine = abs(math.cos(radians))
sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
rotated_width = element["width"] * cosine + element["height"] * sine
rotated_height = element["width"] * sine + element["height"] * cosine
return {
"x": element["x"] - (rotated_width - element["width"]) / 2,
"y": element["y"] - (rotated_height - element["height"]) / 2,
"width": rotated_width,
"height": rotated_height,
}
def detect_elements_out_of_canvas(
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for table in (element for element in elements if element["kind"] == "table"):
for element in (
element
for element in elements
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] == "text")
):
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-table["x"], 0),
"top": max(-table["y"], 0),
"right": max(table["x"] + table["width"] - slide_width, 0),
"bottom": max(table["y"] + table["height"] - slide_height, 0),
"left": max(-bbox["x"], 0),
"top": max(-bbox["y"], 0),
"right": max(bbox["x"] + bbox["width"] - slide_width, 0),
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
}
overflow_details = [
f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
@@ -982,23 +1016,20 @@ def detect_table_out_of_canvas(
issues.append(
{
"level": "error",
"code": "table_out_of_canvas",
"elements": [table["id"]],
"code": f'{element["kind"]}_out_of_canvas',
"elements": [element["id"]],
"canvas": {"width": slide_width, "height": slide_height},
"bbox": {
"x": table["x"],
"y": table["y"],
"width": table["width"],
"height": table["height"],
},
"bbox": bbox,
"overflow": overflow,
"message": (
f'table {table["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
f'({", ".join(overflow_details)})'
),
"hint": (
"Move the table inside the canvas, reduce table.width/table.height, or split the table across "
"slides."
if element["kind"] == "table"
else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
),
}
)
@@ -1083,7 +1114,7 @@ def lint_slide(
elements = extract_elements(slide_xml)
issues: list[dict[str, Any]] = [
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
*detect_table_out_of_canvas(elements, slide_width, slide_height),
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
*detect_table_layout_size_mismatches(elements),
]

View File

@@ -217,7 +217,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
self.assertEqual(result["summary"]["slide_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -601,7 +602,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
def test_lint_xml_does_not_check_bounds_or_text_height(self) -> None:
def test_lint_xml_reports_text_out_of_canvas_but_not_text_height(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -618,8 +619,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(issue["code"], "shape_out_of_canvas")
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -674,7 +678,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(elements[1]["fontSize"], 28)
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
def test_lint_xml_does_not_check_small_out_of_bounds_elements(self) -> None:
def test_lint_xml_allows_small_out_of_bounds_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -688,7 +692,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_ignores_obviously_misplaced_large_visuals(self) -> None:
def test_lint_xml_allows_out_of_canvas_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -703,7 +707,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_allows_reasonable_large_visual_bleed(self) -> None:
def test_lint_xml_allows_full_bleed_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -717,6 +721,72 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
<img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
<chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100"/>
<whiteboard id="outside-whiteboard" topLeftX="100" topLeftY="500" width="100" height="100"/>
</data>
</slide>
</presentation>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
[
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
],
)
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rotated-text" type="text" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
<chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
</data>
</slide>
</presentation>
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(issues_by_element["rotated-text"]["code"], "shape_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["left"], 20.710678, places=5)
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["top"], 20.710678, places=5)
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="infinite" type="text" topLeftX="-10" topLeftY="0" width="20" height="20" rotation="inf"/>
<shape id="negative-infinite" type="text" topLeftX="0" topLeftY="-10" width="20" height="20" rotation="-inf"/>
<chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan"/>
</data>
</slide>
</presentation>
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 3)
self.assertEqual(issues_by_element["infinite"]["overflow"], {"left": 10, "top": 0, "right": 0, "bottom": 0})
self.assertEqual(issues_by_element["negative-infinite"]["overflow"], {"left": 0, "top": 10, "right": 0, "bottom": 0})
self.assertEqual(issues_by_element["not-a-number"]["overflow"], {"left": 0, "top": 0, "right": 10, "bottom": 0})
def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""