mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Compare commits
11 Commits
feat/sheet
...
fix/sheets
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f16ad9c1 | ||
|
|
f057a0ed1c | ||
|
|
4dddb0b0e2 | ||
|
|
4b2fec1e5b | ||
|
|
c8abdee931 | ||
|
|
7ee6705490 | ||
|
|
8d2c6c8d40 | ||
|
|
33e6683c5d | ||
|
|
b53d2b5330 | ||
|
|
c9705ad44d | ||
|
|
16738410ef |
@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
|
||||
}
|
||||
|
||||
if isAbsolutePath(raw) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw)
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
@@ -1068,7 +1068,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if rctx.stdinConsumed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--"+fl.Name).
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name)
|
||||
}
|
||||
rctx.stdinConsumed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
@@ -1102,9 +1102,16 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
verr := ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
if slices.Contains(fl.Input, Stdin) {
|
||||
// Rejected @file paths are usually absolute (temp files under
|
||||
// /tmp). Steer toward stdin rather than cd / copying the file
|
||||
// into the project tree.
|
||||
verr = verr.WithHint("this flag also reads stdin: --%s - < %s", fl.Name, path)
|
||||
}
|
||||
return verr
|
||||
}
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
|
||||
@@ -227,6 +227,35 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when
|
||||
// an @file path is rejected (typically an absolute /tmp path): flags that
|
||||
// also accept stdin must point at `--flag - < path` — never at cd'ing into
|
||||
// the target directory or copying the file into the project tree.
|
||||
func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "")
|
||||
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
|
||||
|
||||
err := resolveInputFlags(rctx, flags)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected @file path")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--csv")
|
||||
if !strings.Contains(vErr.Hint, "--csv - < /tmp/does-not-exist.csv") {
|
||||
t.Errorf("hint %q should show the stdin form of the same call", vErr.Hint)
|
||||
}
|
||||
|
||||
// A flag without stdin support must not get the stdin hint.
|
||||
rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "")
|
||||
err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected @file path")
|
||||
}
|
||||
vErr = assertValidationParam(t, err, "--file")
|
||||
if strings.Contains(vErr.Hint, "stdin") {
|
||||
t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUTF8BOM(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
{"leading BOM removed", "\uFEFFhello", "hello"},
|
||||
|
||||
@@ -102,8 +102,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
{
|
||||
shortcut: "+rows-resize",
|
||||
sc: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
|
||||
subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--height", "30"},
|
||||
subInput: `{"sheet-id":"sh1","range":"1","height":30}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cols-resize",
|
||||
@@ -409,12 +409,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
wantContains: "--count must be > 0",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --type pixel without --size",
|
||||
name: "+rows-resize --height with --type standard",
|
||||
shortcut: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--height", "30", "--type", "standard"},
|
||||
subShortcut: "+rows-resize",
|
||||
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
|
||||
wantContains: "--type pixel requires --size",
|
||||
subInput: `{"sheet-id":"sh1","range":"1:2","height":30,"type":"standard"}`,
|
||||
wantContains: "--height cannot be combined with --type standard",
|
||||
},
|
||||
{
|
||||
name: "+sheet-delete missing sheet selector",
|
||||
@@ -469,6 +469,34 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsResizeMapForm locks the nesting guard: the map form
|
||||
// (--widths/--heights) expands into its own batch_update, and batch_update
|
||||
// cannot nest, so a +batch-update sub-op carrying `widths`/`heights` must be
|
||||
// rejected with a pointer to the standalone form — it is standalone-valid,
|
||||
// so this case cannot live in the standalone-vs-batch equivalence table.
|
||||
func TestBatchOp_RejectsResizeMapForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
shortcut string
|
||||
input string
|
||||
}{
|
||||
{"+cols-resize", `{"sheet-id":"sh1","widths":{"A":100}}`},
|
||||
{"+rows-resize", `{"sheet-id":"sh1","heights":{"1":50}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.input), &subInput); err != nil {
|
||||
t.Fatalf("bad input JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.shortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
|
||||
// silent-coercion gap: `operations` skips parse-time schema validation, and
|
||||
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
|
||||
@@ -611,10 +639,10 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
"--position is required",
|
||||
},
|
||||
{
|
||||
"+rows-resize missing --type",
|
||||
"+rows-resize missing both --height and --type",
|
||||
"+rows-resize",
|
||||
`{"sheet-id":"sh1","range":"1:1"}`,
|
||||
"--type is required",
|
||||
"give --height <px> for a pixel size, or --type standard / auto",
|
||||
},
|
||||
{
|
||||
"+range-copy missing --target-range",
|
||||
@@ -802,7 +830,7 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
|
||||
// bare single-element ranges.
|
||||
body = parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","height":40}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -118,10 +119,19 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
}},
|
||||
|
||||
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
|
||||
// The map form (--heights/--widths) fans out into its own batch_update
|
||||
// and cannot nest inside +batch-update; sub-ops must use the uniform
|
||||
// single-range form (range + height/width or type).
|
||||
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectResizeMapInBatch(fv, "row"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resizeInput(fv, token, sid, sname, "row")
|
||||
}},
|
||||
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectResizeMapInBatch(fv, "column"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resizeInput(fv, token, sid, sname, "column")
|
||||
}},
|
||||
|
||||
@@ -197,6 +207,54 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
|
||||
}
|
||||
|
||||
// allowedBatchShortcuts lists every shortcut accepted inside +batch-update,
|
||||
// sorted, for the not-allowed error hint.
|
||||
func allowedBatchShortcuts() []string {
|
||||
out := make([]string, 0, len(batchOpDispatch))
|
||||
for sc := range batchOpDispatch {
|
||||
out = append(out, sc)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// subOpInputContract renders one shortcut's complete sub-op key vocabulary
|
||||
// (wire-style underscore names) for the translator-failure hint: required
|
||||
// flags are marked, the sheet selector pair collapses to a choose-one, and
|
||||
// spreadsheet locators are omitted (reserved for the batch top level).
|
||||
// Returns "" for shortcuts without a flag-defs entry.
|
||||
func subOpInputContract(sc string) string {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[sc]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
idFlag, nameFlag := sheetSelectorFlagsForSubOp(sc)
|
||||
var keys []string
|
||||
sheetSelector := ""
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Hidden {
|
||||
continue
|
||||
}
|
||||
switch df.Name {
|
||||
case "url", "spreadsheet-token":
|
||||
continue // reserved: supplied by +batch-update top level
|
||||
case idFlag, nameFlag:
|
||||
sheetSelector = strings.ReplaceAll(idFlag, "-", "_") + "|" + strings.ReplaceAll(nameFlag, "-", "_") + " (choose one)"
|
||||
continue
|
||||
}
|
||||
key := strings.ReplaceAll(df.Name, "-", "_")
|
||||
if df.Required == "required" {
|
||||
key += " (required)"
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if sheetSelector != "" {
|
||||
keys = append([]string{sheetSelector}, keys...)
|
||||
}
|
||||
return strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// rejectLocalImageInBatch blocks the local-file --image source inside
|
||||
// +batch-update: a batch sub-op has no upload phase, so the file could not be
|
||||
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
||||
@@ -262,7 +320,8 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}
|
||||
scRaw, present := op["shortcut"]
|
||||
if !present {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index)
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index).
|
||||
WithHint(`each entry must look like {"shortcut":"+cells-set","input":{"sheet_name":"…","range":"A1:B2","cells":[[…]]}} — input uses the shortcut's own flag names`)
|
||||
}
|
||||
sc, ok := scRaw.(string)
|
||||
if !ok || sc == "" {
|
||||
@@ -270,13 +329,15 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
}
|
||||
mapping, ok := batchOpDispatch[sc]
|
||||
if !ok {
|
||||
// Inline the full allow-list: an agent that guessed a read op or a
|
||||
// fan-out wrapper can pick the right shortcut immediately instead of
|
||||
// spending a --print-schema round trip on the operations enum.
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
|
||||
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
index, sc,
|
||||
)
|
||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
||||
}
|
||||
inputRaw, hasInput := op["input"]
|
||||
var input map[string]interface{}
|
||||
@@ -324,7 +385,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
|
||||
body, err := mapping.translate(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
// The inner error names one problem at a time (first missing flag);
|
||||
// the hint lists the sub-op's complete key contract so an agent fixes
|
||||
// every gap in a single retry instead of iterating flag by flag.
|
||||
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
if contract := subOpInputContract(sc); contract != "" {
|
||||
verr = verr.WithHint("%s input keys: %s", sc, contract)
|
||||
}
|
||||
return nil, verr
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": mapping.mcpToolName,
|
||||
@@ -345,7 +413,9 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
}
|
||||
if len(rawOps) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps))
|
||||
batches := (len(rawOps) + maxBatchOperations - 1) / maxBatchOperations
|
||||
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps)).
|
||||
WithHint("split the operations into %d separate +batch-update calls of at most %d entries each", batches, maxBatchOperations)
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
for i, raw := range rawOps {
|
||||
|
||||
92
shortcuts/sheets/csv_put_stdin_fallback_test.go
Normal file
92
shortcuts/sheets/csv_put_stdin_fallback_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// +csv-put lets a piped CSV satisfy an omitted --csv: agents routinely redirect
|
||||
// a file into stdin but forget the `--csv -`. PostMount relaxes the required
|
||||
// gate and installs a PreRunE that, when stdin is a non-interactive pipe,
|
||||
// defaults an absent --csv to "-" so the standard stdin path reads it. On an
|
||||
// interactive terminal the fallback stays off so the command never blocks.
|
||||
func mountCsvPut(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "sheets"}
|
||||
CsvPut.Mount(parent, f)
|
||||
cmd, _, err := parent.Find([]string{"+csv-put"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find(+csv-put) error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func csvRequiredAnnotationPresent(cmd *cobra.Command) bool {
|
||||
fl := cmd.Flags().Lookup("csv")
|
||||
if fl == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := fl.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
return ok
|
||||
}
|
||||
|
||||
// withStdinIsPipe swaps the package-level pipe detector for the duration of a
|
||||
// test so behavior does not depend on the real process stdin.
|
||||
func withStdinIsPipe(t *testing.T, piped bool) {
|
||||
t.Helper()
|
||||
prev := csvPutStdinIsPipe
|
||||
csvPutStdinIsPipe = func() bool { return piped }
|
||||
t.Cleanup(func() { csvPutStdinIsPipe = prev })
|
||||
}
|
||||
|
||||
func TestCsvPutPostMount_RelaxesCsvRequired(t *testing.T) {
|
||||
cmd := mountCsvPut(t)
|
||||
if csvRequiredAnnotationPresent(cmd) {
|
||||
t.Error("--csv required annotation should be relaxed so csvPutInput reports the typed error")
|
||||
}
|
||||
if cmd.PreRunE == nil {
|
||||
t.Fatal("PostMount should install a PreRunE for the stdin fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsvPutPreRunE_PipedAbsentDefaultsToDash(t *testing.T) {
|
||||
withStdinIsPipe(t, true)
|
||||
cmd := mountCsvPut(t)
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("PreRunE error = %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("csv"); got != "-" {
|
||||
t.Errorf("csv = %q, want %q (piped + absent should default to '-')", got, "-")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsvPutPreRunE_InteractiveAbsentStaysEmpty(t *testing.T) {
|
||||
withStdinIsPipe(t, false)
|
||||
cmd := mountCsvPut(t)
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("PreRunE error = %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("csv"); got != "" {
|
||||
t.Errorf("csv = %q, want empty (interactive stdin must not be consumed / must not hang)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsvPutPreRunE_ExplicitValueUnchanged(t *testing.T) {
|
||||
withStdinIsPipe(t, true)
|
||||
cmd := mountCsvPut(t)
|
||||
if err := cmd.Flags().Set("csv", "x,y"); err != nil {
|
||||
t.Fatalf("Set(csv) error = %v", err)
|
||||
}
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("PreRunE error = %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("csv"); got != "x,y" {
|
||||
t.Errorf("csv = %q, want %q (explicit value must not be overridden)", got, "x,y")
|
||||
}
|
||||
}
|
||||
@@ -159,8 +159,11 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
|
||||
"default": "sheet"
|
||||
"desc": "New sub-sheet type: sheet (spreadsheet); default sheet.",
|
||||
"default": "sheet",
|
||||
"enum": [
|
||||
"sheet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -2390,32 +2393,43 @@
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "height",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "xor",
|
||||
"desc": "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "heights",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)",
|
||||
"required": "xor",
|
||||
"desc": "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`",
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"
|
||||
"required": "xor",
|
||||
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -2457,31 +2471,42 @@
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "xor",
|
||||
"desc": "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "widths",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)",
|
||||
"required": "xor",
|
||||
"desc": "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`",
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise",
|
||||
"default": "0"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)"
|
||||
"required": "xor",
|
||||
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -4950,40 +4975,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+undo": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet locator"
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.",
|
||||
"default": "1"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+changeset-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
|
||||
@@ -4286,6 +4286,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cols-resize": {
|
||||
"widths": {
|
||||
"type": "object",
|
||||
"description": "列 → 宽度 map。键:单列(\"A\")或列闭区间(\"C:E\");值:正整数像素宽(如 80 / 120 / 200;⚠️ 单位是像素,不是 Excel 字符单位,像素 ≈ 字符数×8+16)或 \"standard\"(重置为默认列宽)。列宽不支持 \"auto\"。",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "像素宽度(宽度 < 20px 会被 CLI 拒绝并提示疑似字符单位)"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"standard"
|
||||
],
|
||||
"description": "重置为默认列宽"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
"properties": {
|
||||
"description": "创建/更新的条件格式属性。",
|
||||
@@ -7143,6 +7165,29 @@
|
||||
"description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。"
|
||||
}
|
||||
},
|
||||
"+rows-resize": {
|
||||
"heights": {
|
||||
"type": "object",
|
||||
"description": "行 → 高度 map。键:单行(\"1\")或行闭区间(\"2:20\");值:正整数像素高(如 30 / 50;⚠️ 单位是像素,不是磅/points)、\"auto\"(自适应内容)或 \"standard\"(重置为默认行高)。",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "像素高度"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"description": "standard=重置默认行高;auto=自适应内容"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+sparkline-create": {
|
||||
"properties": {
|
||||
"description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。",
|
||||
|
||||
@@ -252,9 +252,10 @@ var flagDefs = map[string]commandDef{
|
||||
{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: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
|
||||
{Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"},
|
||||
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -799,9 +800,10 @@ var flagDefs = map[string]commandDef{
|
||||
{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: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
|
||||
{Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"},
|
||||
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -826,7 +828,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet); default sheet.", Default: "sheet", Enum: []string{"sheet"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -995,15 +997,6 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+undo": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "Number of user undo stack entries to undo sequentially. Defaults to 1, maximum 20. Entries are undone from newest to oldest; the actual count is returned in undone.", Default: "1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
|
||||
238
shortcuts/sheets/flag_ergonomics.go
Normal file
238
shortcuts/sheets/flag_ergonomics.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// ─── sheets flag ergonomics ─────────────────────────────────────────────
|
||||
//
|
||||
// Eval traces show two recovery loops that burn agent round-trips on the
|
||||
// sheets domain specifically: hallucinated flag names (--cols for --range,
|
||||
// --file for --csv) whose unknown-flag error only points at --help, and
|
||||
// enum values imported from CSS / Excel vocabulary ("center" for the
|
||||
// vertical alignment Lark spells "middle"). Both fixes are wired through
|
||||
// the existing PostMount hook — composed onto any prior PostMount in
|
||||
// Shortcuts(), same pattern as withTokenAlias — so the common framework
|
||||
// needs no change at all and no other domain's behavior shifts.
|
||||
|
||||
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
|
||||
// the command gets the sheets-specific unknown-flag error (valid flags
|
||||
// inlined) and enum-value normalization (canonical vocabulary auto-applied,
|
||||
// typos suggested).
|
||||
func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// set: hallucinated sheets flags are usually semantic guesses (--cols for
|
||||
// --range) that edit distance can't rank, and a --help round trip costs an
|
||||
// agent a full extra call. One line here lets it re-issue the command
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = "valid flags: " + list
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean %s? valid flags: %s",
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// unknownFlagFromParseError extracts the offending long-flag name from
|
||||
// cobra's flag-parse error text ("unknown flag: --query" → "query").
|
||||
// Returns ok=false for anything else (missing argument, invalid value,
|
||||
// unknown shorthand) so those stay structured but generic. Mirrors the
|
||||
// root-level parser in cmd; the prefix contract is cobra's English wording.
|
||||
func unknownFlagFromParseError(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names registered on c, sorted.
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// inlineFlagListLimit caps how many flag names ride inline on an
|
||||
// unknown-flag hint. Sheets shortcuts stay well under it.
|
||||
const inlineFlagListLimit = 25
|
||||
|
||||
// inlineFlagList renders valid flag names as one comma-separated line for
|
||||
// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when
|
||||
// there is nothing to list.
|
||||
func inlineFlagList(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
shown := names
|
||||
var suffix string
|
||||
if len(names) > inlineFlagListLimit {
|
||||
shown = names[:inlineFlagListLimit]
|
||||
suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit)
|
||||
}
|
||||
parts := make([]string, len(shown))
|
||||
for i, n := range shown {
|
||||
parts[i] = "--" + n
|
||||
}
|
||||
return strings.Join(parts, ", ") + suffix
|
||||
}
|
||||
|
||||
// ─── enum vocabulary normalization ──────────────────────────────────────
|
||||
|
||||
// enumAliases maps habitual values agents import from CSS / Excel / Google
|
||||
// Sheets onto the value the Lark API actually uses, keyed by the wrong
|
||||
// value. Applied only when the alias target is in the enum (and the wrong
|
||||
// value is not), so e.g. "center" still stands for horizontal alignment
|
||||
// (where it is valid) and only maps to "middle" for vertical alignment.
|
||||
var enumAliases = map[string]string{
|
||||
"center": "middle", // CSS vertical-align: center → Lark "middle"
|
||||
"centre": "center",
|
||||
"middle": "center", // CSS-style middle → Lark horizontal "center"
|
||||
}
|
||||
|
||||
// canonicalEnumValue returns the enum entry an off-vocabulary value
|
||||
// unambiguously means — exact case-insensitive match first, then the
|
||||
// cross-vocabulary alias table. Unlike an edit-distance guess, the result
|
||||
// is safe to apply on the caller's behalf. Returns "" when the value has
|
||||
// no unambiguous canonical form in this enum.
|
||||
func canonicalEnumValue(val string, enum []string) string {
|
||||
lower := strings.ToLower(val)
|
||||
for _, allowed := range enum {
|
||||
if strings.ToLower(allowed) == lower {
|
||||
return allowed
|
||||
}
|
||||
}
|
||||
if target, ok := enumAliases[lower]; ok {
|
||||
if slices.Contains(enum, target) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// closestEnumValue picks the best "did you mean" candidate for an invalid
|
||||
// enum value: the unambiguous canonical form first, then edit distance.
|
||||
// For prose suggestions only — an edit-distance match must never be
|
||||
// auto-applied. Returns "" when nothing is close.
|
||||
func closestEnumValue(val string, enum []string) string {
|
||||
if canon := canonicalEnumValue(val, enum); canon != "" {
|
||||
return canon
|
||||
}
|
||||
if match := suggest.Closest(val, enum, 1); len(match) > 0 {
|
||||
return match[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// chainEnumNormalization installs a PreRunE stage (composed onto any
|
||||
// framework-set PreRunE, which runs first so OnInvoke side effects and the
|
||||
// --print-schema required-flag relaxation keep their contracts) that
|
||||
// normalizes the command's flat enum flags before the common runner
|
||||
// validates them:
|
||||
//
|
||||
// - an unambiguous vocabulary mismatch (casing, or a known alias like CSS
|
||||
// "center" for Lark's vertical "middle") IS the value the caller meant —
|
||||
// rewrite it in place and proceed instead of failing the call just to
|
||||
// have the agent retype the canonical spelling;
|
||||
// - anything else fails here with the allowed list plus a "did you mean"
|
||||
// hint for edit-distance typos — a guess is never auto-applied.
|
||||
//
|
||||
// No-op for commands whose flag defs declare no enums.
|
||||
func chainEnumNormalization(cmd *cobra.Command) {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[cmd.Name()]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var enumFlags []flagDef
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "system" && len(df.Enum) > 0 && df.Type == "string" {
|
||||
enumFlags = append(enumFlags, df)
|
||||
}
|
||||
}
|
||||
if len(enumFlags) == 0 {
|
||||
return
|
||||
}
|
||||
prev := cmd.PreRunE
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
if prev != nil {
|
||||
if err := prev(c, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// --print-schema is pure local introspection; the runner never enum-
|
||||
// validates that path, so don't start here.
|
||||
if want, err := c.Flags().GetBool("print-schema"); err == nil && want {
|
||||
return nil
|
||||
}
|
||||
for _, df := range enumFlags {
|
||||
val, err := c.Flags().GetString(df.Name)
|
||||
if err != nil || val == "" || slices.Contains(df.Enum, val) {
|
||||
continue
|
||||
}
|
||||
if canon := canonicalEnumValue(val, df.Enum); canon != "" {
|
||||
c.Flags().Set(df.Name, canon)
|
||||
continue
|
||||
}
|
||||
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
|
||||
val, df.Name, strings.Join(df.Enum, ", ")).
|
||||
WithParam("--" + df.Name)
|
||||
if match := suggest.Closest(val, df.Enum, 1); len(match) > 0 {
|
||||
verr = verr.WithHint("did you mean %q?", match[0])
|
||||
}
|
||||
return verr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
296
shortcuts/sheets/flag_ergonomics_test.go
Normal file
296
shortcuts/sheets/flag_ergonomics_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagFromParseError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --cols", "cols", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagFromParseError(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets
|
||||
// override of the root unknown-flag error: --cols is a semantic guess for
|
||||
// --range that edit distance can't rank, so the hint must inline the full
|
||||
// valid-flag list instead of deferring to a --help round trip.
|
||||
func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().Int("width", 0, "")
|
||||
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" {
|
||||
t.Errorf("Params = %v, want one entry named --cols", verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "--help") {
|
||||
t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint)
|
||||
}
|
||||
for _, want := range []string{"--range", "--width"} {
|
||||
if !strings.Contains(verr.Hint, want) {
|
||||
t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior
|
||||
// (did-you-mean suggestion, machine-readable Suggestions) is preserved by
|
||||
// the sheets override, with the valid-flag list appended.
|
||||
func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
found := false
|
||||
for _, s := range verr.Params[0].Suggestions {
|
||||
if s == "--range" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
for _, want := range []string{"did you mean", "--range", "--dry-run"} {
|
||||
if !strings.Contains(verr.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, verr.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if verr.Param != "" || len(verr.Params) != 0 {
|
||||
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineFlagList_TruncatesPastLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := inlineFlagList(nil); got != "" {
|
||||
t.Errorf("inlineFlagList(nil) = %q, want empty", got)
|
||||
}
|
||||
names := make([]string, inlineFlagListLimit+5)
|
||||
for i := range names {
|
||||
names[i] = fmt.Sprintf("flag-%02d", i)
|
||||
}
|
||||
got := inlineFlagList(names)
|
||||
if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") {
|
||||
t.Errorf("truncated list should count the overflow and defer to --help, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, names[inlineFlagListLimit]) {
|
||||
t.Errorf("list should stop at the limit, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalEnumValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
val string
|
||||
enum []string
|
||||
want string
|
||||
}{
|
||||
{"SUM", []string{"sum", "count"}, "sum"}, // casing
|
||||
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias: CSS vertical center
|
||||
{"middle", []string{"left", "center", "right"}, "center"}, // alias: horizontal middle
|
||||
{"overwite", []string{"append", "overwrite"}, ""}, // typo is NOT canonical
|
||||
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := canonicalEnumValue(c.val, c.enum); got != c.want {
|
||||
t.Errorf("canonicalEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosestEnumValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
val string
|
||||
enum []string
|
||||
want string
|
||||
}{
|
||||
{"SUM", []string{"sum", "count"}, "sum"}, // casing
|
||||
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias
|
||||
{"overwite", []string{"append", "overwrite"}, "overwrite"}, // edit distance
|
||||
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := closestEnumValue(c.val, c.enum); got != c.want {
|
||||
t.Errorf("closestEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChainEnumNormalization_UnitContract pins the PreRunE stage in
|
||||
// isolation: canonical vocabulary is auto-applied, typos error with a
|
||||
// suggestion (never applied), the framework PreRunE keeps running first,
|
||||
// and --print-schema skips enum gating entirely.
|
||||
func TestChainEnumNormalization_UnitContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
newCmd := func() (*cobra.Command, *bool) {
|
||||
cmd := &cobra.Command{Use: "+cells-set-style"}
|
||||
cmd.Flags().String("vertical-alignment", "", "")
|
||||
cmd.Flags().Bool("print-schema", false, "")
|
||||
prevCalled := false
|
||||
cmd.PreRunE = func(*cobra.Command, []string) error {
|
||||
prevCalled = true
|
||||
return nil
|
||||
}
|
||||
chainEnumNormalization(cmd)
|
||||
return cmd, &prevCalled
|
||||
}
|
||||
|
||||
// Alias auto-applied, framework PreRunE preserved.
|
||||
cmd, prevCalled := newCmd()
|
||||
cmd.Flags().Set("vertical-alignment", "center")
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Fatalf("center should normalize and pass, got: %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "middle" {
|
||||
t.Errorf("vertical-alignment = %q, want rewritten to %q", got, "middle")
|
||||
}
|
||||
if !*prevCalled {
|
||||
t.Error("framework PreRunE must keep running first")
|
||||
}
|
||||
|
||||
// Typo: error with suggestion, value untouched.
|
||||
cmd, _ = newCmd()
|
||||
cmd.Flags().Set("vertical-alignment", "botom")
|
||||
err := cmd.PreRunE(cmd, nil)
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("typo should fail with *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, `"bottom"`) {
|
||||
t.Errorf("hint should suggest bottom for the typo, got %q", verr.Hint)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "botom" {
|
||||
t.Errorf("typo must not be rewritten, got %q", got)
|
||||
}
|
||||
|
||||
// --print-schema skips enum gating (pure local introspection).
|
||||
cmd, _ = newCmd()
|
||||
cmd.Flags().Set("vertical-alignment", "not-a-value")
|
||||
cmd.Flags().Set("print-schema", "true")
|
||||
if err := cmd.PreRunE(cmd, nil); err != nil {
|
||||
t.Errorf("--print-schema must skip enum gating, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shortcutFromRegistry returns the fully wired shortcut (PostMount
|
||||
// ergonomics included) as Shortcuts() exposes it to the framework.
|
||||
func shortcutFromRegistry(t *testing.T, command string) common.Shortcut {
|
||||
t.Helper()
|
||||
for _, sc := range Shortcuts() {
|
||||
if sc.Command == command {
|
||||
return sc
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found in Shortcuts()", command)
|
||||
return common.Shortcut{}
|
||||
}
|
||||
|
||||
// TestShortcuts_FlagErgonomicsMounted verifies the ergonomics ride every
|
||||
// mounted sheets command end-to-end: enum vocabulary normalizes on a real
|
||||
// invocation, and unknown flags answer with the inlined valid-flag list.
|
||||
func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("enum alias normalizes through a real run", func(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",
|
||||
"--vertical-alignment", "center",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("center should normalize to middle and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "middle") || strings.Contains(stdout, "center") {
|
||||
t.Errorf("dry-run body should carry the normalized value, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enum typo errors with suggestion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--vertical-alignment", "botom",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, `invalid value "botom" for --vertical-alignment`)
|
||||
if !strings.Contains(ve.Hint, `"bottom"`) {
|
||||
t.Errorf("hint should suggest bottom, got %q", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown flag inlines valid flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -97,11 +98,22 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
|
||||
// --properties) are the highest-frequency usage-layer failure for
|
||||
// sheets, and agents often burn several retries guessing the shape.
|
||||
// Point them straight at --print-schema, which dumps the exact JSON
|
||||
// Schema for this (command, flag) pair. The hint is always actionable:
|
||||
// reaching this branch means entry[name] resolved a schema from the
|
||||
// embedded index, and --print-schema reads that same index, so the
|
||||
// suggested command is guaranteed to print it.
|
||||
// A shallow type mismatch means the caller misremembered the overall
|
||||
// container shape (the classic {"cells": ...} wrapper around what
|
||||
// must be a bare 2D array), so inline a skeleton of the expected
|
||||
// shape — that fixes the retry without a --print-schema round trip.
|
||||
// Deeper failures keep the --print-schema pointer, which dumps the
|
||||
// exact JSON Schema for this (command, flag) pair; reaching this
|
||||
// branch means entry[name] resolved a schema from the embedded
|
||||
// index, so the suggested command is guaranteed to print it.
|
||||
var tm *typeMismatchError
|
||||
if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
|
||||
name, vErr.Error(), sk, command, name).WithCause(vErr)
|
||||
}
|
||||
}
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, vErr.Error(), command, name).WithCause(vErr)
|
||||
@@ -243,7 +255,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +291,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if !matched {
|
||||
msg := fmt.Sprintf("%svalue %s is not in enum %s",
|
||||
pathPrefix(path), formatJSONValue(value), formatEnum(schema.Enum))
|
||||
if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
|
||||
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
@@ -388,6 +400,126 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// typeMismatchError is the type-check branch of validateAgainstSchema
|
||||
// as a typed error, so validateValueAgainstSchema can recognize shape
|
||||
// confusion (vs. deep value errors) and inline a skeleton of the
|
||||
// expected shape. Error() keeps the exact legacy wording.
|
||||
type typeMismatchError struct {
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
}
|
||||
|
||||
func (e *typeMismatchError) Error() string {
|
||||
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
|
||||
}
|
||||
|
||||
// pathDepth counts how many levels below the flag root a JSON path
|
||||
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
|
||||
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
|
||||
// segment; a leading bare key (no bracket) is one segment of its own.
|
||||
func pathDepth(path string) int {
|
||||
depth := strings.Count(path, "[") + strings.Count(path, ".")
|
||||
if path != "" && path[0] != '[' {
|
||||
depth++
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
// Skeleton rendering bounds: a mismatch at depth ≤ 2 is container-shape
|
||||
// confusion worth a skeleton; deeper mismatches are value-level and the
|
||||
// full schema pointer serves better. The skeleton itself stops after
|
||||
// four levels and eight keys per object so it stays one line; a wide
|
||||
// object (> skeletonWideObject keys) collapses its children to type
|
||||
// placeholders so every key stays visible instead of the first branch
|
||||
// eating the whole line.
|
||||
const (
|
||||
skeletonPathDepthLimit = 2
|
||||
skeletonMaxDepth = 4
|
||||
skeletonMaxKeys = 8
|
||||
skeletonWideObject = 2
|
||||
)
|
||||
|
||||
// schemaSkeleton renders a compact single-line sketch of the shape a
|
||||
// schema expects, e.g. [[{"value": …, "formula": "…", …}]] for
|
||||
// +cells-set --cells. Required keys come first, then alphabetical,
|
||||
// capped at skeletonMaxKeys with a trailing … marker. Values render as
|
||||
// their type placeholder; enum strings show the first allowed value.
|
||||
func schemaSkeleton(s *schemaProperty, depth int) string {
|
||||
if s == nil {
|
||||
return "…"
|
||||
}
|
||||
if len(s.OneOf) > 0 && s.Type == "" {
|
||||
return schemaSkeleton(s.OneOf[0], depth)
|
||||
}
|
||||
switch s.Type {
|
||||
case "array":
|
||||
if depth <= 0 {
|
||||
return "[…]"
|
||||
}
|
||||
return "[" + schemaSkeleton(s.Items, depth-1) + "]"
|
||||
case "object":
|
||||
if depth <= 0 || len(s.Properties) == 0 {
|
||||
return "{…}"
|
||||
}
|
||||
keys := skeletonKeys(s)
|
||||
childDepth := depth - 1
|
||||
if len(s.Properties) > skeletonWideObject {
|
||||
childDepth = 0
|
||||
}
|
||||
parts := make([]string, 0, len(keys)+1)
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%q: %s", k, schemaSkeleton(s.Properties[k], childDepth)))
|
||||
}
|
||||
if len(s.Properties) > len(keys) {
|
||||
parts = append(parts, "…")
|
||||
}
|
||||
return "{" + strings.Join(parts, ", ") + "}"
|
||||
case "string":
|
||||
if len(s.Enum) > 0 {
|
||||
return formatJSONValue(s.Enum[0])
|
||||
}
|
||||
return `"…"`
|
||||
case "number", "integer":
|
||||
return "0"
|
||||
case "boolean":
|
||||
return "false"
|
||||
}
|
||||
return "…"
|
||||
}
|
||||
|
||||
// skeletonKeys picks which object keys a skeleton shows: required keys
|
||||
// first (schema order), then remaining keys alphabetically, capped at
|
||||
// skeletonMaxKeys.
|
||||
func skeletonKeys(s *schemaProperty) []string {
|
||||
keys := make([]string, 0, skeletonMaxKeys)
|
||||
seen := make(map[string]struct{}, skeletonMaxKeys)
|
||||
for _, k := range s.Required {
|
||||
if _, ok := s.Properties[k]; !ok {
|
||||
continue
|
||||
}
|
||||
if len(keys) == skeletonMaxKeys {
|
||||
return keys
|
||||
}
|
||||
keys = append(keys, k)
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
rest := make([]string, 0, len(s.Properties))
|
||||
for k := range s.Properties {
|
||||
if _, dup := seen[k]; !dup {
|
||||
rest = append(rest, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(rest)
|
||||
for _, k := range rest {
|
||||
if len(keys) == skeletonMaxKeys {
|
||||
break
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func matchesJSONType(value interface{}, expected string) bool {
|
||||
switch expected {
|
||||
case "object":
|
||||
@@ -473,25 +605,48 @@ func joinFormatted(values []interface{}) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// suggestEnumMatch returns a "did you mean" candidate when the user's
|
||||
// value differs from an allowed enum entry only in casing — the most
|
||||
// common real-world mistake ("SUM" vs "sum", "True" vs "true"). The
|
||||
// match is restricted to strings; non-string enums (numbers, etc.)
|
||||
// don't have a casing notion. Returns "" when no near-miss exists.
|
||||
// suggestEnumMatch returns the canonical enum entry when the user's
|
||||
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs
|
||||
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical
|
||||
// "middle"). Callers auto-apply the result, so it must stay restricted
|
||||
// to unambiguous matches (edit-distance guesses belong in
|
||||
// suggestEnumForError only). Non-string values have no vocabulary
|
||||
// notion. Returns "" when no unambiguous match exists.
|
||||
func suggestEnumMatch(value interface{}, values []interface{}) string {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
canon := canonicalEnumValue(s, stringEnumEntries(values))
|
||||
if canon == "" || canon == s { // skip exact-equal (already would have matched).
|
||||
return ""
|
||||
}
|
||||
return canon
|
||||
}
|
||||
|
||||
// stringEnumEntries extracts the string members of a JSON-schema enum
|
||||
// list (mixed-type enums keep only their string entries).
|
||||
func stringEnumEntries(values []interface{}) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
if vs, ok := v.(string); ok && strings.ToLower(vs) == lower {
|
||||
if vs != s { // skip exact-equal (already would have matched).
|
||||
return vs
|
||||
}
|
||||
if vs, ok := v.(string); ok {
|
||||
out = append(out, vs)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestEnumForError picks the "did you mean" candidate for an enum
|
||||
// error message. Unlike suggestEnumMatch (whose result is auto-applied,
|
||||
// so it must stay unambiguous), this one may also draw on edit distance
|
||||
// — the suggestion is only prose, the user still has to re-issue the
|
||||
// value explicitly.
|
||||
func suggestEnumForError(value interface{}, values []interface{}) string {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return closestEnumValue(s, stringEnumEntries(values))
|
||||
}
|
||||
|
||||
func pathPrefix(path string) string {
|
||||
|
||||
@@ -360,6 +360,142 @@ func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_EnumAliasNormalized pins the cross-vocabulary
|
||||
// auto-fix: CSS-habit "center" for a vertical alignment unambiguously means
|
||||
// Lark's "middle", so the payload is normalized in place and the call
|
||||
// proceeds — same treatment as the "SUM" vs "sum" casing class.
|
||||
func TestValidateAgainstSchema_EnumAliasNormalized(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{"vertical_alignment":{"type":"string","enum":["top","middle","bottom"]}}
|
||||
}`)
|
||||
obj := map[string]interface{}{"vertical_alignment": "center"}
|
||||
if err := validateAgainstSchema(obj, schema, ""); err != nil {
|
||||
t.Fatalf("center should normalize to middle and pass, got: %v", err)
|
||||
}
|
||||
if got := obj["vertical_alignment"]; got != "middle" {
|
||||
t.Errorf("vertical_alignment = %q, want normalized to %q", got, "middle")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_EnumTypoSuggestedNotApplied pins the auto-apply
|
||||
// boundary on the error path: an edit-distance typo stays an error with a
|
||||
// "did you mean" suggestion, never a silent rewrite.
|
||||
func TestValidateAgainstSchema_EnumTypoSuggestedNotApplied(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{"order":{"type":"string","enum":["asc","desc"]}}
|
||||
}`)
|
||||
obj := map[string]interface{}{"order": "ascc"}
|
||||
err := validateAgainstSchema(obj, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("typo must be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `did you mean "asc"?`) {
|
||||
t.Errorf("enum error should suggest asc for the typo, got %q", err.Error())
|
||||
}
|
||||
if got := obj["order"]; got != "ascc" {
|
||||
t.Errorf("typo must not be rewritten, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch
|
||||
// pins the highest-frequency eval failure: passing an object where
|
||||
// --cells expects a 2D array must inline a skeleton of the expected
|
||||
// shape (with the "value" key visible) so an agent fixes the retry
|
||||
// without a --print-schema round trip.
|
||||
func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
err := validateValueAgainstSchema(fv, "cells",
|
||||
map[string]interface{}{"cells": []interface{}{}})
|
||||
if err == nil {
|
||||
t.Fatal("object where array expected must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{`expected type "array", got "object"`, "expected shape: [[{", `"value"`} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("error should contain %q, got %q", want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Deep value-level mismatch keeps the plain --print-schema pointer
|
||||
// (a whole-shape skeleton would not address the actual problem).
|
||||
deep := []interface{}{[]interface{}{
|
||||
map[string]interface{}{"note": 12.5},
|
||||
}}
|
||||
err = validateValueAgainstSchema(fv, "cells", deep)
|
||||
if err == nil {
|
||||
t.Fatal("wrong type for note must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "expected shape:") {
|
||||
t.Errorf("deep mismatch should not inline a skeleton, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--print-schema") {
|
||||
t.Errorf("deep mismatch should keep the --print-schema pointer, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"", 0},
|
||||
{"[0]", 1},
|
||||
{"[0][3]", 2},
|
||||
{"[0][3].value", 3},
|
||||
{"legend", 1},
|
||||
{"snapshot.axes", 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pathDepth(c.path); got != c.want {
|
||||
t.Errorf("pathDepth(%q) = %d, want %d", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSkeleton(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"array",
|
||||
"items":{
|
||||
"type":"array",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"value":{},
|
||||
"formula":{"type":"string"},
|
||||
"align":{"type":"string","enum":["top","middle","bottom"]},
|
||||
"styles":{"type":"object","properties":{"bold":{"type":"boolean"}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
got := schemaSkeleton(schema, skeletonMaxDepth)
|
||||
// Wide object (>2 keys) collapses nested containers to placeholders;
|
||||
// enum strings surface their first allowed value.
|
||||
want := `[[{"align": "top", "formula": "…", "styles": {…}, "value": …}]]`
|
||||
if got != want {
|
||||
t.Errorf("skeleton = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
narrow := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"required":["sheets"],
|
||||
"properties":{"sheets":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}
|
||||
}`)
|
||||
got = schemaSkeleton(narrow, skeletonMaxDepth)
|
||||
// Narrow object (≤2 keys) keeps descending so the inner shape shows.
|
||||
want = `{"sheets": [{"name": "…"}]}`
|
||||
if got != want {
|
||||
t.Errorf("narrow skeleton = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_NilSchemaSafe pins the defensive
|
||||
// `if schema == nil { return nil }` guard. Current production callers
|
||||
// always hand validator a real schema, but the guard means future
|
||||
|
||||
@@ -19,6 +19,7 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+cells-set-style": {},
|
||||
"+chart-create": {},
|
||||
"+chart-update": {},
|
||||
"+cols-resize": {},
|
||||
"+cond-format-create": {},
|
||||
"+cond-format-update": {},
|
||||
"+dropdown-set": {},
|
||||
@@ -30,6 +31,7 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+pivot-create": {},
|
||||
"+pivot-update": {},
|
||||
"+range-sort": {},
|
||||
"+rows-resize": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -419,6 +420,94 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the
|
||||
// highest-frequency batch failures, so an agent can repair its payload in a
|
||||
// single retry without --help / --print-schema round trips.
|
||||
func TestBatchUpdate_PrescriptiveHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
opsJSON string
|
||||
wantMatch string
|
||||
wantInHint []string
|
||||
}{
|
||||
{
|
||||
name: "missing shortcut gets entry template",
|
||||
opsJSON: `[{"input":{"range":"A1"}}]`,
|
||||
wantMatch: "'shortcut' field is required",
|
||||
wantInHint: []string{`{"shortcut":"+cells-set"`, `"input"`},
|
||||
},
|
||||
{
|
||||
name: "disallowed shortcut lists the allow-list inline",
|
||||
opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`,
|
||||
wantMatch: "not allowed in +batch-update",
|
||||
wantInHint: []string{"allowed shortcuts:", "+cells-set-style", "+range-copy"},
|
||||
},
|
||||
{
|
||||
name: "translator failure lists full key contract",
|
||||
opsJSON: `[{"shortcut":"+dim-insert","input":{"sheet_name":"s"}}]`,
|
||||
wantMatch: "--position is required",
|
||||
wantInHint: []string{"+dim-insert input keys:", "sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", tc.opsJSON,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, tc.wantMatch)
|
||||
for _, want := range tc.wantInHint {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranslateBatchOperations_OverLimitSplitHint pins the split
|
||||
// prescription on the 100-entry cap: the hint must say how many batches
|
||||
// the caller should re-issue.
|
||||
func TestTranslateBatchOperations_OverLimitSplitHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops := make([]interface{}, 185)
|
||||
for i := range ops {
|
||||
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
|
||||
}
|
||||
_, err := translateBatchOperations(ops, "shtcnX")
|
||||
ve := requireValidation(t, err, "accepts at most 100 entries; got 185")
|
||||
for _, want := range []string{"2 separate +batch-update calls", "at most 100 entries each"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubOpInputContract pins the contract line derivation from flag-defs:
|
||||
// reserved spreadsheet locators are omitted, the sheet selector collapses
|
||||
// to a choose-one, and required flags are marked.
|
||||
func TestSubOpInputContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := subOpInputContract("+dim-insert")
|
||||
for _, want := range []string{"sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("contract should contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"url", "spreadsheet_token", "dry_run"} {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Errorf("contract must not expose %q, got %q", banned, got)
|
||||
}
|
||||
}
|
||||
if got := subOpInputContract("+no-such-shortcut"); got != "" {
|
||||
t.Errorf("unknown shortcut should yield empty contract, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_DimFreezeInjectsFreeze covers the static-freeze-only
|
||||
// path: +dim-freeze always injects operation=freeze (count==0 unfreeze
|
||||
// path of the single shortcut is intentionally not supported in batch).
|
||||
@@ -447,7 +536,7 @@ func TestBatchUpdate_ResizeNoOperationField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","height":30}}]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
|
||||
@@ -43,15 +43,15 @@ var ChangesetGet = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := changesetInput(runtime)
|
||||
input, _ := changesetInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_changeset", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := changesetInput(runtime)
|
||||
input, err := changesetInput(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -90,12 +90,13 @@ func changesetRevisions(runtime flagView) (start int, end int, err error) {
|
||||
|
||||
// changesetInput builds the get_changeset tool input. end_revision is only
|
||||
// sent when explicitly provided; otherwise the server defaults to latest.
|
||||
func changesetInput(runtime flagView) (map[string]interface{}, error) {
|
||||
func changesetInput(runtime flagView, token string) (map[string]interface{}, error) {
|
||||
start, end, err := changesetRevisions(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"start_revision": start,
|
||||
}
|
||||
if end > 0 {
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestChangesetGet_DryRun(t *testing.T) {
|
||||
name: "start + end bounded range",
|
||||
args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"start_revision": float64(120),
|
||||
"end_revision": float64(135),
|
||||
},
|
||||
@@ -31,6 +32,7 @@ func TestChangesetGet_DryRun(t *testing.T) {
|
||||
name: "start only → end omitted (server defaults to latest)",
|
||||
args: []string{"--url", testURL, "--start-revision", "120"},
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"start_revision": float64(120),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -208,77 +210,81 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// resize_range exposes two CLI shortcuts:
|
||||
// resize_range exposes two CLI shortcuts, each with two input forms:
|
||||
//
|
||||
// +rows-resize / +cols-resize — set row heights / column widths. --type
|
||||
// enum (pixel / standard / [auto]) controls how: --type pixel needs --size,
|
||||
// --type standard restores the sheet default, --type auto auto-fits row
|
||||
// heights (rows only). --range is an A1 closed range ("2:10" / "5" rows or
|
||||
// "A:E" / "C" columns); single-element form is expanded to "N:N" before
|
||||
// send because resize_range rejects bare single-element ranges.
|
||||
// +rows-resize / +cols-resize — set row heights / column widths.
|
||||
//
|
||||
// Uniform form: --range + --height/--width <px>; the pixel mode is implied
|
||||
// so --type can be omitted (or set to `pixel` — equivalent). Non-pixel
|
||||
// modes go through --type standard / --type auto (rows only) and cannot be
|
||||
// combined with the pixel flag. --range is an A1 closed range ("2:10" /
|
||||
// "5" rows or "A:E" / "C" columns); single-element form is expanded to
|
||||
// "N:N" before send because resize_range rejects bare single-element
|
||||
// ranges.
|
||||
//
|
||||
// Map form: --heights / --widths carries a JSON object of per-row/column
|
||||
// sizes ({"A": 100, "C:E": 120, "G": "standard"}) and fans out into one
|
||||
// atomic batch_update of resize_range ops — different sizes for many
|
||||
// rows/columns in a single CLI call, no +batch-update needed. Mutually
|
||||
// exclusive with --range/--height/--width/--type, and not accepted as a
|
||||
// +batch-update sub-op (nested batch_update is unsupported upstream).
|
||||
//
|
||||
// Wire shape: resize_height / resize_width carries { type, value? }, e.g.
|
||||
// { "type": "pixel", "value": 30 } or { "type": "standard" }.
|
||||
//
|
||||
// Units are pixels. Column widths in Excel character units (openpyxl /
|
||||
// xlsxwriter mental model, px ≈ chars × 8 + 16) are a real agent trap, so
|
||||
// widths below minSaneColumnWidthPx are rejected with a conversion hint.
|
||||
|
||||
// RowsResize wraps resize_range for row heights. --type auto enables
|
||||
// auto-fit (rows only); --type pixel requires --size.
|
||||
// RowsResize wraps resize_range for row heights. Pass --range + --height
|
||||
// <px> for a uniform pixel height, --heights '{"1":50,"2:20":30}' for
|
||||
// per-row heights, or --type standard/auto for non-pixel modes.
|
||||
var RowsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+rows-resize",
|
||||
Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --range is 1-based A1 like \"2:10\" or \"5\").",
|
||||
Description: "Resize rows in pixels: --range + --height <px> for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one atomic call, or --type standard/auto (--range is 1-based A1 like \"2:10\" or \"5\").",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+rows-resize"),
|
||||
Validate: validateViaResize("row"),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := resizeInput(runtime, token, sheetID, sheetName, "row")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", 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 := resizeInput(runtime, token, sheetID, sheetName, "row")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
DryRun: resizeDryRun("row"),
|
||||
Execute: resizeExecute("row"),
|
||||
}
|
||||
|
||||
// ColsResize wraps resize_range for column widths. Column widths do not
|
||||
// support auto-fit — --type only accepts pixel / standard.
|
||||
// ColsResize wraps resize_range for column widths. Pass --range + --width
|
||||
// <px> for a uniform pixel width, --widths '{"A":100,"C:E":120}' for
|
||||
// per-column widths, or --type standard for the default width. Column
|
||||
// widths do not support auto-fit — --type does not accept auto.
|
||||
var ColsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cols-resize",
|
||||
Description: "Resize columns by pixel / standard (--type pixel needs --size; --range is column letters like \"A:E\" or \"C\"; no auto for cols).",
|
||||
Description: "Resize columns in pixels (NOT Excel char units): --range + --width <px> for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one atomic call, or --type standard to reset (--range is column letters like \"A:E\" or \"C\"; no auto for cols).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cols-resize"),
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
}
|
||||
|
||||
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall
|
||||
// so the uniform form hits resize_range and the map form hits batch_update
|
||||
// with identical inputs in preview and execution.
|
||||
func resizeDryRun(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := resizeInput(runtime, token, sheetID, sheetName, "column")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
toolName, input, _ := resizeToolCall(runtime, token, sheetID, sheetName, dimension)
|
||||
return invokeToolDryRun(token, ToolKindWrite, toolName, input)
|
||||
}
|
||||
}
|
||||
|
||||
func resizeExecute(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -287,22 +293,21 @@ var ColsResize = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := resizeInput(runtime, token, sheetID, sheetName, "column")
|
||||
toolName, input, err := resizeToolCall(runtime, token, sheetID, sheetName, dimension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, toolName, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// validateViaResize wires the standalone Validate to resizeInput so both
|
||||
// paths (standalone + batch sub-op) emit the same error for missing --type,
|
||||
// malformed --range, or --type auto on columns.
|
||||
// validateViaResize wires the standalone Validate to resizeToolCall so both
|
||||
// forms (uniform + map) are fully validated before execution.
|
||||
func validateViaResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
@@ -311,17 +316,82 @@ func validateViaResize(dimension string) func(ctx context.Context, runtime *comm
|
||||
}
|
||||
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
|
||||
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
|
||||
_, err = resizeInput(runtime, token, sheetID, sheetName, dimension)
|
||||
_, _, err = resizeToolCall(runtime, token, sheetID, sheetName, dimension)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// autoSuffix appends " / auto" to the enum hint for rows.
|
||||
func autoSuffix(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return " / auto"
|
||||
// resizeToolCall picks the input form: map form (--heights/--widths) builds a
|
||||
// batch_update of resize_range ops; uniform form builds a single resize_range
|
||||
// input. Returns the tool name to invoke alongside its input.
|
||||
func resizeToolCall(runtime flagView, token, sheetID, sheetName, dimension string) (string, map[string]interface{}, error) {
|
||||
if runtime.Changed(sizeMapFlag(dimension)) {
|
||||
input, err := resizeMapInput(runtime, token, sheetID, sheetName, dimension)
|
||||
return "batch_update", input, err
|
||||
}
|
||||
return ""
|
||||
input, err := resizeInput(runtime, token, sheetID, sheetName, dimension)
|
||||
return "resize_range", input, err
|
||||
}
|
||||
|
||||
// nonPixelTypes lists the --type values a given dimension accepts (rows also
|
||||
// accept auto; columns only accept standard). Used to shape the hint printed
|
||||
// when --type is missing or invalid.
|
||||
func nonPixelTypes(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return "standard / auto"
|
||||
}
|
||||
return "standard"
|
||||
}
|
||||
|
||||
// pixelFlag maps a dimension to its pixel-value flag name (--height for rows,
|
||||
// --width for cols). The wire block always emits "pixel" as the mode; the
|
||||
// per-dimension flag name is just the surface knob.
|
||||
func pixelFlag(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return "height"
|
||||
}
|
||||
return "width"
|
||||
}
|
||||
|
||||
// sizeMapFlag maps a dimension to its map-form flag name (--heights for rows,
|
||||
// --widths for cols).
|
||||
func sizeMapFlag(dimension string) string {
|
||||
return pixelFlag(dimension) + "s"
|
||||
}
|
||||
|
||||
// rejectResizeMapInBatch blocks the map form inside +batch-update sub-ops:
|
||||
// it expands into its own batch_update and nesting batch_update is
|
||||
// unsupported upstream. Called by the batch dispatch closures only — the
|
||||
// standalone path routes the map form through resizeMapInput instead.
|
||||
func rejectResizeMapInBatch(fv flagView, dimension string) error {
|
||||
mapFlag := sizeMapFlag(dimension)
|
||||
if !fv.Changed(mapFlag) {
|
||||
return nil
|
||||
}
|
||||
return sheetsValidationForFlag(mapFlag,
|
||||
"%q is not supported inside +batch-update (it expands into its own atomic batch); call %s --%s standalone, or give each sub-op the single-range form (range + %s/type)",
|
||||
mapFlag, commandForDimension(dimension), mapFlag, pixelFlag(dimension))
|
||||
}
|
||||
|
||||
// minSaneColumnWidthPx is the floor below which a column width almost
|
||||
// certainly means the caller thought in Excel character units (openpyxl /
|
||||
// xlsxwriter widths run 8-30 chars) instead of pixels. 10px columns are
|
||||
// unusable; real pixel spacer columns start around 20px.
|
||||
const minSaneColumnWidthPx = 20
|
||||
|
||||
// checkPixelSize validates a pixel value for one dimension. label names the
|
||||
// offending input in the error ("--width" for the uniform flag, "--widths
|
||||
// key \"A\"" for a map entry).
|
||||
func checkPixelSize(dimension, flagName, label string, px int) error {
|
||||
if px <= 0 {
|
||||
return sheetsValidationForFlag(flagName, "%s must be > 0", label)
|
||||
}
|
||||
if dimension == "column" && px < minSaneColumnWidthPx {
|
||||
return sheetsValidationForFlag(flagName,
|
||||
"%s = %dpx is below %dpx and looks like an Excel character-unit width — column widths here are pixels (px ≈ chars × 8 + 16, so %d chars ≈ %dpx)",
|
||||
label, px, minSaneColumnWidthPx, px, px*8+16)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// commandForDimension returns the shortcut command name a given dimension
|
||||
@@ -339,6 +409,11 @@ func commandForDimension(dimension string) string {
|
||||
// dimension (row → digits like "2:10" / "5"; column → letters like "A:E" /
|
||||
// "C"). Single-element form is expanded to "N:N" because resize_range
|
||||
// rejects bare single-element ranges.
|
||||
//
|
||||
// Surface: pixel size goes through --height / --width (dimension-specific).
|
||||
// --type is optional when the pixel flag is present (defaults to "pixel");
|
||||
// explicit --type pixel is accepted and equivalent. --type standard / auto
|
||||
// select non-pixel modes and cannot be combined with the pixel flag.
|
||||
func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -361,29 +436,42 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
|
||||
if !strings.Contains(rangeStr, ":") {
|
||||
rangeStr = rangeStr + ":" + rangeStr
|
||||
}
|
||||
|
||||
sizeFlag := pixelFlag(dimension)
|
||||
hasSize := runtime.Changed(sizeFlag)
|
||||
typ := strings.TrimSpace(runtime.Str("type"))
|
||||
if typ == "" {
|
||||
return nil, sheetsValidationForFlag("type", "--type is required (pixel / standard%s)", autoSuffix(dimension))
|
||||
hasType := typ != ""
|
||||
|
||||
if !hasSize && !hasType {
|
||||
return nil, common.ValidationErrorf("give --%s <px> for a pixel size, or --type %s", sizeFlag, nonPixelTypes(dimension)).WithParams(sheetsInvalidParam(sizeFlag, "required"), sheetsInvalidParam("type", "required"))
|
||||
}
|
||||
if dimension == "column" && typ == "auto" {
|
||||
if hasSize && hasType && typ != "pixel" {
|
||||
return nil, common.ValidationErrorf("--%s cannot be combined with --type %s", sizeFlag, typ).WithParams(sheetsInvalidParam(sizeFlag, "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive"))
|
||||
}
|
||||
if hasType && dimension == "column" && typ == "auto" {
|
||||
return nil, sheetsValidationForFlag("type", "--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
|
||||
}
|
||||
hasSize := runtime.Changed("size") && runtime.Int("size") > 0
|
||||
if typ == "pixel" && !hasSize {
|
||||
return nil, common.ValidationErrorf("--type pixel requires --size <px>").WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam("size", "required"))
|
||||
if hasType && typ == "pixel" && !hasSize {
|
||||
return nil, common.ValidationErrorf("--type pixel requires --%s <px>", sizeFlag).WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam(sizeFlag, "required"))
|
||||
}
|
||||
if typ != "pixel" && hasSize {
|
||||
return nil, common.ValidationErrorf("--size is only valid with --type pixel").WithParams(sheetsInvalidParam("size", "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive"))
|
||||
|
||||
sizeBlock := map[string]interface{}{}
|
||||
if hasSize {
|
||||
px := runtime.Int(sizeFlag)
|
||||
if err := checkPixelSize(dimension, sizeFlag, "--"+sizeFlag, px); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sizeBlock["type"] = "pixel"
|
||||
sizeBlock["value"] = px
|
||||
} else {
|
||||
sizeBlock["type"] = typ
|
||||
}
|
||||
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": rangeStr,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
sizeBlock := map[string]interface{}{"type": typ}
|
||||
if typ == "pixel" {
|
||||
sizeBlock["value"] = runtime.Int("size")
|
||||
}
|
||||
if dimension == "row" {
|
||||
input["resize_height"] = sizeBlock
|
||||
} else {
|
||||
@@ -392,6 +480,122 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// resizeMapInput builds the batch_update input for the map form: every
|
||||
// --heights/--widths entry becomes one resize_range op inside a single atomic
|
||||
// batch. Keys are single rows/columns ("5" / "A") or closed ranges ("2:8" /
|
||||
// "C:E") matching the command's dimension; values are positive pixel ints or
|
||||
// the non-pixel mode strings ("standard", and "auto" for rows). Ops are
|
||||
// sorted by start position so dry-run output and execution order are
|
||||
// deterministic (JSON object order is not preserved by Go maps).
|
||||
func resizeMapInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapFlag := sizeMapFlag(dimension)
|
||||
for _, other := range []string{"range", pixelFlag(dimension), "type"} {
|
||||
if runtime.Changed(other) {
|
||||
return nil, common.ValidationErrorf("--%s is a self-contained map; do not combine it with --%s", mapFlag, other).WithParams(sheetsInvalidParam(mapFlag, "mutually exclusive"), sheetsInvalidParam(other, "mutually exclusive"))
|
||||
}
|
||||
}
|
||||
parsed, err := parseJSONFlag(runtime, mapFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, ok := parsed.(map[string]interface{})
|
||||
if !ok || parsed == nil {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s must be a JSON object like {\"%s\": 100}", mapFlag, exampleMapKey(dimension))
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s must contain at least one entry", mapFlag)
|
||||
}
|
||||
|
||||
type resizeOp struct {
|
||||
start int
|
||||
input map[string]interface{}
|
||||
}
|
||||
ops := make([]resizeOp, 0, len(entries))
|
||||
seen := make(map[string]string, len(entries)) // normalized range → original key
|
||||
for key, raw := range entries {
|
||||
parsedDim, startIdx, _, err := parseA1Range(key)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s key %q: %v", mapFlag, key, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers (e.g. \"2:10\")"
|
||||
if dimension == "column" {
|
||||
want = "column letters (e.g. \"A:E\")"
|
||||
}
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s key %q is a %s range; %s expects %s", mapFlag, key, parsedDim, commandForDimension(dimension), want)
|
||||
}
|
||||
normalized := strings.TrimSpace(key)
|
||||
if !strings.Contains(normalized, ":") {
|
||||
normalized = normalized + ":" + normalized
|
||||
}
|
||||
if prev, dup := seen[normalized]; dup {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s keys %q and %q target the same range %s; merge them into one entry", mapFlag, prev, key, normalized)
|
||||
}
|
||||
seen[normalized] = key
|
||||
|
||||
sizeBlock := map[string]interface{}{}
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
px := int(v)
|
||||
if float64(px) != v {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] must be an integer pixel value, got %v", mapFlag, key, v)
|
||||
}
|
||||
if err := checkPixelSize(dimension, mapFlag, fmt.Sprintf("--%s[%q]", mapFlag, key), px); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sizeBlock["type"] = "pixel"
|
||||
sizeBlock["value"] = px
|
||||
case string:
|
||||
mode := strings.TrimSpace(v)
|
||||
if mode == "auto" && dimension == "column" {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q]: \"auto\" is rows-only (column widths do not support auto-fit); estimate a pixel width instead (px ≈ chars × 8 + 16)", mapFlag, key)
|
||||
}
|
||||
if mode != "standard" && !(mode == "auto" && dimension == "row") {
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] = %q is invalid; use a pixel integer or %s", mapFlag, key, v, nonPixelTypes(dimension))
|
||||
}
|
||||
sizeBlock["type"] = mode
|
||||
default:
|
||||
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] must be a pixel integer or a mode string (%s), got %s", mapFlag, key, nonPixelTypes(dimension), jsonTypeName(raw))
|
||||
}
|
||||
|
||||
opInput := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": normalized,
|
||||
}
|
||||
sheetSelectorForToolInput(opInput, sheetID, sheetName)
|
||||
if dimension == "row" {
|
||||
opInput["resize_height"] = sizeBlock
|
||||
} else {
|
||||
opInput["resize_width"] = sizeBlock
|
||||
}
|
||||
ops = append(ops, resizeOp{start: startIdx, input: opInput})
|
||||
}
|
||||
|
||||
sort.Slice(ops, func(i, j int) bool { return ops[i].start < ops[j].start })
|
||||
operations := make([]interface{}, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
operations = append(operations, map[string]interface{}{
|
||||
"tool_name": "resize_range",
|
||||
"input": op.input,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": operations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// exampleMapKey renders a dimension-appropriate sample key for error hints.
|
||||
func exampleMapKey(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return "2:10"
|
||||
}
|
||||
return "A"
|
||||
}
|
||||
|
||||
// ─── transform_range (4 shortcuts) ────────────────────────────────────
|
||||
//
|
||||
// move / copy take --source-range + --target-range (+ optional cross-sheet
|
||||
|
||||
@@ -113,9 +113,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --range 1:5 pixel 200",
|
||||
name: "+rows-resize --range 1:5 --height 200",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"},
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "200"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
@@ -138,7 +138,7 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --range B:D standard",
|
||||
name: "+cols-resize --range B:D --type standard",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"},
|
||||
toolName: "resize_range",
|
||||
@@ -152,9 +152,22 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --range A:C pixel 120",
|
||||
name: "+cols-resize --range A:C --width 120",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"},
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "120"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"range": "A:C",
|
||||
"resize_width": map[string]interface{}{
|
||||
"type": "pixel",
|
||||
"value": float64(120),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --type pixel with --width 120 (explicit == implicit)",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--width", "120"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"range": "A:C",
|
||||
@@ -296,6 +309,163 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResize_MapForm covers the --widths/--heights map form: entries fan out
|
||||
// into one atomic batch_update of resize_range ops, sorted by start position
|
||||
// regardless of JSON key order.
|
||||
func TestResize_MapForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("+cols-resize --widths mixes pixels, ranges and standard", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, ColsResize, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--widths", `{"G": "standard", "A": 100, "C:E": 120}`,
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
wantOps := []interface{}{
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "A:A",
|
||||
"resize_width": map[string]interface{}{"type": "pixel", "value": float64(100)},
|
||||
}},
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "C:E",
|
||||
"resize_width": map[string]interface{}{"type": "pixel", "value": float64(120)},
|
||||
}},
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "G:G",
|
||||
"resize_width": map[string]interface{}{"type": "standard"},
|
||||
}},
|
||||
}
|
||||
assertInputEquals(t, input, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operations": wantOps,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("+rows-resize --heights mixes pixels, auto and standard", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, RowsResize, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--heights", `{"21": "auto", "1": 50, "2:20": 30}`,
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
wantOps := []interface{}{
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "1:1",
|
||||
"resize_height": map[string]interface{}{"type": "pixel", "value": float64(50)},
|
||||
}},
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "2:20",
|
||||
"resize_height": map[string]interface{}{"type": "pixel", "value": float64(30)},
|
||||
}},
|
||||
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
|
||||
"excel_id": testToken, "sheet_id": testSheetID, "range": "21:21",
|
||||
"resize_height": map[string]interface{}{"type": "auto"},
|
||||
}},
|
||||
}
|
||||
assertInputEquals(t, input, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operations": wantOps,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestResize_MapFormGuards covers map-form validation: exclusivity with the
|
||||
// uniform flags, key/value shape errors, the char-unit width floor, and the
|
||||
// +batch-update nesting rejection.
|
||||
func TestResize_MapFormGuards(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "--widths rejects --range",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100}`, "--range", "A:C"},
|
||||
want: "--widths is a self-contained map; do not combine it with --range",
|
||||
},
|
||||
{
|
||||
name: "--widths rejects --width",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100}`, "--width", "120"},
|
||||
want: "--widths is a self-contained map; do not combine it with --width",
|
||||
},
|
||||
{
|
||||
name: "--heights rejects --type",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": 50}`, "--type", "auto"},
|
||||
want: "--heights is a self-contained map; do not combine it with --type",
|
||||
},
|
||||
{
|
||||
name: "--widths empty object",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{}`},
|
||||
want: "must contain at least one entry",
|
||||
},
|
||||
{
|
||||
name: "--widths row key on cols command",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"2:8": 100}`},
|
||||
want: "+cols-resize expects column letters",
|
||||
},
|
||||
{
|
||||
name: "--heights column key on rows command",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"A": 50}`},
|
||||
want: "+rows-resize expects row numbers",
|
||||
},
|
||||
{
|
||||
name: "--widths duplicate keys A and A:A",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100, "A:A": 120}`},
|
||||
want: "target the same range A:A",
|
||||
},
|
||||
{
|
||||
name: "--widths char-unit width rejected with conversion hint",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 10}`},
|
||||
want: "looks like an Excel character-unit width",
|
||||
},
|
||||
{
|
||||
// The embedded schema (enum ["standard"]) rejects "auto" before the
|
||||
// Go-level rows-only hint; the error steers to --print-schema whose
|
||||
// description explains columns don't support auto.
|
||||
name: "--widths rejects auto (rows-only) via schema",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": "auto"}`},
|
||||
want: "does not match any of oneOf alternatives",
|
||||
},
|
||||
{
|
||||
name: "--heights rejects unknown mode string via schema",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": "fit"}`},
|
||||
want: "does not match any of oneOf alternatives",
|
||||
},
|
||||
{
|
||||
name: "--heights rejects boolean value via schema",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": true}`},
|
||||
want: "does not match any of oneOf alternatives",
|
||||
},
|
||||
{
|
||||
name: "--widths bad key syntax",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A1:B2": 100}`},
|
||||
want: "expected pure digits (row number) or letters",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
|
||||
requireValidation(t, err, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResize_TypeAndSizeGuards(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -305,22 +475,58 @@ func TestResize_TypeAndSizeGuards(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "+rows-resize --type pixel without --size",
|
||||
name: "+rows-resize missing both --height and --type",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"},
|
||||
want: "--type pixel requires --size",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"},
|
||||
want: "give --height <px> for a pixel size, or --type standard / auto",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --type standard with --size",
|
||||
name: "+cols-resize missing both --width and --type",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C"},
|
||||
want: "give --width <px> for a pixel size, or --type standard",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --height rejects --type standard",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"},
|
||||
want: "--size is only valid with --type pixel",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "30", "--type", "standard"},
|
||||
want: "--height cannot be combined with --type standard",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --width rejects --type standard",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "120", "--type", "standard"},
|
||||
want: "--width cannot be combined with --type standard",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --type pixel without --height",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"},
|
||||
want: "--type pixel requires --height",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --type pixel without --width",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel"},
|
||||
want: "--type pixel requires --width",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --height must be positive",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "0"},
|
||||
want: "--height must be > 0",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --width below 20px rejected with char-unit hint",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "12"},
|
||||
want: "looks like an Excel character-unit width",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize rejects --type auto",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "auto"},
|
||||
want: "auto", // cobra Enum gate kicks first with "valid values are: pixel, standard"
|
||||
want: "auto", // cobra Enum gate kicks first with "valid values are: standard"
|
||||
},
|
||||
{
|
||||
name: "+rows-resize given column range",
|
||||
|
||||
@@ -317,17 +317,52 @@ func (in *tableSheetIn) normalize(idx int) (tableSheetSpec, error) {
|
||||
// compare against the canonical set.
|
||||
for k := range in.Dtypes {
|
||||
if !seenCol[k] {
|
||||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k)
|
||||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k).
|
||||
WithHint("%s", columnKeyHint("dtypes", k, in.Columns))
|
||||
}
|
||||
}
|
||||
for k := range in.Formats {
|
||||
if !seenCol[k] {
|
||||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k)
|
||||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k).
|
||||
WithHint("%s", columnKeyHint("formats", k, in.Columns))
|
||||
}
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// columnKeyHint explains a dtypes/formats key that matched no column. The
|
||||
// dominant failure is Excel habit — keying by column letter (A/B/AA) instead
|
||||
// of the column name — so call that out explicitly; either way, inline the
|
||||
// declared column names so the retry needs no second look at the payload.
|
||||
func columnKeyHint(field, key string, columns []string) string {
|
||||
shown := columns
|
||||
const maxShown = 12
|
||||
suffix := ""
|
||||
if len(shown) > maxShown {
|
||||
shown = shown[:maxShown]
|
||||
suffix = ", …"
|
||||
}
|
||||
list := `"` + strings.Join(shown, `", "`) + `"` + suffix
|
||||
if isColumnLetterKey(key) {
|
||||
return fmt.Sprintf("%s keys must be column names from `columns`, not A1-style column letters; this sheet's columns: %s", field, list)
|
||||
}
|
||||
return fmt.Sprintf("%s keys must exactly match a name in `columns`: %s", field, list)
|
||||
}
|
||||
|
||||
// isColumnLetterKey reports whether key looks like an A1-style column letter
|
||||
// (A, B, AA, …) rather than a real column name.
|
||||
func isColumnLetterKey(key string) bool {
|
||||
if key == "" || len(key) > 3 {
|
||||
return false
|
||||
}
|
||||
for _, r := range key {
|
||||
if r < 'A' || r > 'Z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *tablePayload) validate() error {
|
||||
if len(p.Sheets) == 0 {
|
||||
return common.ValidationErrorf("--sheets: must contain at least one sheet")
|
||||
@@ -584,6 +619,12 @@ var excelEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
|
||||
// parser still rejects it cleanly.
|
||||
func isoDateToSerial(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
// Empty cells in a date-typed column are the classic header/total-row
|
||||
// clash with the column-wide dtype declaration; name the three ways
|
||||
// out so the caller does not have to guess what "bad format" means.
|
||||
return 0, fmt.Errorf("date column has an empty cell — drop the empty rows, fill real yyyy-mm-dd dates, or declare the column dtype as object (text)") //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||||
}
|
||||
if i := strings.Index(s, "T"); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
|
||||
@@ -54,6 +54,67 @@ func TestTablePut_IsoDateToSerial(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_EmptyDatePrescription pins the empty-cell branch: the error
|
||||
// must name the three ways out (drop rows / fill dates / object dtype)
|
||||
// instead of the generic "must be ISO" parse failure.
|
||||
func TestTablePut_EmptyDatePrescription(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, in := range []string{"", " "} {
|
||||
_, err := isoDateToSerial(in)
|
||||
if err == nil {
|
||||
t.Fatalf("isoDateToSerial(%q) should fail", in)
|
||||
}
|
||||
for _, want := range []string{"empty cell", "object (text)"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("isoDateToSerial(%q) error should contain %q, got %q", in, want, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_ColumnKeyHint pins the dtypes/formats unknown-column hint:
|
||||
// A1-style letter keys get the Excel-habit callout, and the declared column
|
||||
// names ride inline either way.
|
||||
func TestTablePut_ColumnKeyHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
cols := []string{"姓名", "出生日期"}
|
||||
got := columnKeyHint("dtypes", "A", cols)
|
||||
for _, want := range []string{"not A1-style column letters", `"姓名", "出生日期"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("letter-key hint should contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
got = columnKeyHint("formats", "出生 日期", cols)
|
||||
if strings.Contains(got, "A1-style") {
|
||||
t.Errorf("non-letter key must not get the letter callout, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"姓名", "出生日期"`) {
|
||||
t.Errorf("hint should inline column names, got %q", got)
|
||||
}
|
||||
|
||||
many := make([]string, 20)
|
||||
for i := range many {
|
||||
many[i] = fmt.Sprintf("col%02d", i)
|
||||
}
|
||||
got = columnKeyHint("dtypes", "X", many)
|
||||
if !strings.Contains(got, ", …") || strings.Contains(got, "col19") {
|
||||
t.Errorf("hint should truncate long column lists, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsColumnLetterKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"A": true, "Z": true, "AA": true, "ABC": true,
|
||||
"": false, "ABCD": false, "a": false, "A1": false, "姓名": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := isColumnLetterKey(in); got != want {
|
||||
t.Errorf("isColumnLetterKey(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablePut_BuildTypedCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var Undo = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+undo",
|
||||
Description: "Undo the current user's latest spreadsheet write.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
// History shortcuts keep locator flags hand-written because they share
|
||||
// revision/revert helpers; keep --count here in sync with sheet-skill-spec.
|
||||
Flags: append(historyLocatorFlags(),
|
||||
common.Flag{Name: "count", Type: "int", Default: "1", Desc: "Number of user undo stack entries to undo sequentially (1-20)."},
|
||||
),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Int("count") < 1 || runtime.Int("count") > 20 {
|
||||
return sheetsValidationForFlag("count", "--count must be between 1 and 20")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "undo_last", undoInput(runtime, token))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", undoInput(runtime, token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func undoInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
|
||||
// Always send count, including the default 1, so dry-run mirrors the exact
|
||||
// request body sent by Execute.
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"count": runtime.Int("count"),
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUndo_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := []string{"--url", testURL, "--as", "user"}
|
||||
callURL := dryRunFirstCallURL(t, Undo, args)
|
||||
if !containsSuffix(callURL, "invoke_write") {
|
||||
t.Errorf("invoke url = %q, want invoke_write", callURL)
|
||||
}
|
||||
|
||||
body := parseDryRunBody(t, Undo, args)
|
||||
got := decodeToolInput(t, body, "undo_last")
|
||||
assertInputEquals(t, got, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"count": float64(1),
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecute_Undo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := toolOutputStub(testToken, "write", `{"undone":3,"op_id":"op-3","top_doc_revision":30,"new_revision":31,"op_ids":["op-3","op-2","op-1"],"results":[{"op_id":"op-3","top_doc_revision":30,"new_revision":31},{"op_id":"op-2","top_doc_revision":20,"new_revision":32},{"op_id":"op-1","top_doc_revision":10,"new_revision":33}]}`)
|
||||
out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", "3", "--as", "user"}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "undo_last")
|
||||
assertInputEquals(t, input, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"count": float64(3),
|
||||
})
|
||||
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["undone"].(float64) != 3 {
|
||||
t.Fatalf("unexpected output data: %#v", data)
|
||||
}
|
||||
results := data["results"].([]interface{})
|
||||
first := results[0].(map[string]interface{})
|
||||
if data["op_id"] != first["op_id"] ||
|
||||
data["top_doc_revision"] != first["top_doc_revision"] ||
|
||||
data["new_revision"] != first["new_revision"] {
|
||||
t.Fatalf("legacy fields are not aligned with first result: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_ValidateCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
count string
|
||||
wantSub string
|
||||
}{
|
||||
{name: "zero", count: "0", wantSub: "--count must be between 1 and 20"},
|
||||
{name: "negative", count: "-1", wantSub: "--count must be between 1 and 20"},
|
||||
{name: "too large", count: "21", wantSub: "--count must be between 1 and 20"},
|
||||
{name: "non integer", count: "1.5", wantSub: "invalid argument"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--count", tt.count, "--as", "user"})
|
||||
if err == nil {
|
||||
t.Fatal("expected count validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantSub) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_UndoReasonOutputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
outputJSON string
|
||||
wantReason string
|
||||
wantUndone float64
|
||||
}{
|
||||
{
|
||||
name: "partial success because requested count exceeds stack",
|
||||
outputJSON: `{"undone":2,"reason":"undo_stack_empty","op_ids":["op-2","op-1"],"results":[{"op_id":"op-2"},{"op_id":"op-1"}],"warning_message":"Requested 3 undo step(s), but only 2 active undo entries were available."}`,
|
||||
wantReason: "undo_stack_empty",
|
||||
wantUndone: 2,
|
||||
},
|
||||
{
|
||||
name: "raw undo changeset missing or expired",
|
||||
outputJSON: `{"undone":0,"reason":"undo_entry_missing","warning_message":"The undo stack entry points to raw undo changeset data that is missing or expired. Use history_list/history_revert to roll the spreadsheet back via history."}`,
|
||||
wantReason: "undo_entry_missing",
|
||||
wantUndone: 0,
|
||||
},
|
||||
{
|
||||
name: "unsupported object undo",
|
||||
outputJSON: `{"undone":0,"reason":"unsupported_object_undo","warning_message":"The top undo entry contains changes this undo implementation cannot replay."}`,
|
||||
wantReason: "unsupported_object_undo",
|
||||
wantUndone: 0,
|
||||
},
|
||||
{
|
||||
name: "apply failed",
|
||||
outputJSON: `{"undone":0,"reason":"undo_apply_failed","warning_message":"Failed to apply the top undo entry."}`,
|
||||
wantReason: "undo_apply_failed",
|
||||
wantUndone: 0,
|
||||
},
|
||||
{
|
||||
name: "marker persist failed after apply",
|
||||
outputJSON: `{"undone":1,"reason":"undo_state_persist_failed","op_ids":["op-1"],"results":[{"op_id":"op-1","reason":"undo_state_persist_failed","warning_message":"Undo was applied, but the marker was not persisted."}]}`,
|
||||
wantReason: "undo_state_persist_failed",
|
||||
wantUndone: 1,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", tt.outputJSON)
|
||||
out, err := runShortcutWithStubs(t, Undo, []string{"--url", testURL, "--as", "user"}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["undone"].(float64) != tt.wantUndone || data["reason"] != tt.wantReason {
|
||||
t.Fatalf("unexpected output data: %#v", data)
|
||||
}
|
||||
if tt.wantReason == "undo_entry_missing" && !strings.Contains(data["warning_message"].(string), "history_list/history_revert") {
|
||||
t.Fatalf("missing history fallback hint: %#v", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -123,25 +123,12 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return nil, common.ValidationErrorf("--title is required")
|
||||
}
|
||||
// --type bitable 建一张空白多维表格子表(operation=create_bitable);默认 sheet 为普通
|
||||
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令,row-count/col-count 不适用。
|
||||
sheetType := strings.TrimSpace(runtime.Str("type"))
|
||||
if sheetType == "" {
|
||||
sheetType = "sheet"
|
||||
}
|
||||
if sheetType != "sheet" && sheetType != "bitable" {
|
||||
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
|
||||
}
|
||||
if sheetType == "bitable" {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "create_bitable",
|
||||
"sheet_name": strings.TrimSpace(runtime.Str("title")),
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
input["target_index"] = runtime.Int("index")
|
||||
}
|
||||
return input, nil
|
||||
if sheetType != "sheet" {
|
||||
return nil, common.ValidationErrorf("--type must be 'sheet'")
|
||||
}
|
||||
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
|
||||
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
|
||||
@@ -1175,10 +1162,14 @@ func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]work
|
||||
}
|
||||
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 (pixel/standard%s)", path, i, autoSuffix(dimension))
|
||||
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)
|
||||
@@ -1186,7 +1177,7 @@ func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]work
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want pixel/standard%s)", path, i, resizeType, autoSuffix(dimension))
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
|
||||
}
|
||||
size := 0
|
||||
if raw, ok := op["size"]; ok {
|
||||
|
||||
@@ -281,6 +281,12 @@ func TestWorkbook_Validation(t *testing.T) {
|
||||
args: []string{"--url", testURL, "--title", "X", "--row-count", "999999"},
|
||||
wantMsg: "--row-count must be between",
|
||||
},
|
||||
{
|
||||
name: "+sheet-create rejects hidden bitable type",
|
||||
sc: SheetCreate,
|
||||
args: []string{"--url", testURL, "--title", "Tasks", "--type", "bitable"},
|
||||
wantMsg: `invalid value "bitable" for --type`,
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -203,6 +204,19 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// csvPutStdinIsPipe reports whether process stdin is a non-interactive pipe or
|
||||
// redirect (rather than an interactive terminal), so an omitted --csv can be
|
||||
// satisfied from it without risking a hang on a real terminal. Overridable in
|
||||
// tests. A char device is a terminal; anything else (pipe, redirect, /dev/null)
|
||||
// counts as piped input.
|
||||
var csvPutStdinIsPipe = func() bool {
|
||||
fi, err := os.Stdin.Stat() //nolint:forbidigo // pipe detection needs the real process fd; IOStreams.In is a plain io.Reader without Stat
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice == 0
|
||||
}
|
||||
|
||||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
|
||||
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
|
||||
var CsvPut = common.Shortcut{
|
||||
@@ -225,6 +239,27 @@ var CsvPut = common.Shortcut{
|
||||
}
|
||||
cmd.MarkFlagsOneRequired("start-cell", "range")
|
||||
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
|
||||
|
||||
// Let a piped CSV satisfy --csv when the flag is omitted: agents
|
||||
// routinely redirect a file into stdin but forget the `--csv -`, so
|
||||
// `+csv-put ... < data.csv` would otherwise fail its first try on a
|
||||
// missing --csv. Relax the required-gate (flag-defs marks --csv
|
||||
// required) so an absent value surfaces csvPutInput's own typed error
|
||||
// instead of cobra's bare "required flag(s) ... not set"; then, in
|
||||
// PreRunE (which cobra runs before it validates required flags), default
|
||||
// an omitted --csv to "-" when stdin is a non-interactive pipe so the
|
||||
// standard stdin-resolution path reads it. The pipe guard means an
|
||||
// interactive terminal never blocks waiting on stdin — a real miss still
|
||||
// errors.
|
||||
if fl := cmd.Flags().Lookup("csv"); fl != nil {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
||||
if v, _ := c.Flags().GetString("csv"); strings.TrimSpace(v) == "" && csvPutStdinIsPipe() {
|
||||
_ = c.Flags().Set("csv", "-")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := guardCSVValueIsNotFilePath(runtime); err != nil {
|
||||
|
||||
@@ -35,6 +35,10 @@ func Shortcuts() []common.Shortcut {
|
||||
if hasFlag(all[i].Flags, "spreadsheet-token") {
|
||||
all[i].PostMount = withTokenAlias(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.
|
||||
all[i].PostMount = withFlagErgonomics(all[i].PostMount)
|
||||
}
|
||||
return all
|
||||
}
|
||||
@@ -160,6 +164,5 @@ func shortcutList() []common.Shortcut {
|
||||
HistoryList,
|
||||
HistoryRevert,
|
||||
HistoryRevertStatus,
|
||||
Undo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-sheets
|
||||
version: 3.0.1
|
||||
version: 3.0.2
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -159,7 +159,7 @@ metadata:
|
||||
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
|
||||
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
|
||||
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
|
||||
| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本、回滚到指定版本,或撤销当前用户最近一次 AI 工具写入。当用户需要查看编辑历史、回滚历史版本、查询回滚状态,或说“撤销我刚才的修改 / undo 上一步 AI 写入”时使用。历史回滚为异步全表恢复;用户维度 undo 只撤当前用户自己的 undo 栈顶。仅针对飞书表格。 |
|
||||
| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本并回滚到指定版本。当用户需要查看一张表的编辑历史版本列表、回滚到某个历史版本、或查询回滚的异步状态(进行中/成功/失败)时使用。回滚为异步操作,发起后通过状态查询轮询结果。仅针对飞书表格。 |
|
||||
| [Lark Sheet Changeset](references/lark-sheets-changeset.md) | 读取两个版本(CS revision)之间的 changeset(原始变更操作清单),用于复核某次编辑——尤其是 AI 编辑——是否真实满足用户诉求。传入起始版本(编辑前基线),可选结束版本(省略取最新),版本差上限 20;返回里最外层带当前表格最新版本号。当用户需要"看看这次改了什么"、"核对 AI 改动"、"对比两个版本的变更"时使用。 |
|
||||
|
||||
## 公共 flag 速查
|
||||
|
||||
@@ -18,10 +18,11 @@
|
||||
|
||||
**⚠️ 何时必须使用 `+batch-update`(硬性要求)**:
|
||||
- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容)
|
||||
- 需要对**多个**不同区域执行 `+rows-resize / +cols-resize` 时(如统一调整多列列宽或多行行高)
|
||||
- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`)
|
||||
- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合)
|
||||
|
||||
**行高列宽批量不走这里**:多行 / 多列不同尺寸直接用 `+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` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
|
||||
|
||||
**公式相关批处理的默认闭环**:
|
||||
|
||||
@@ -6,35 +6,26 @@
|
||||
|
||||
回滚(revert)把电子表格的当前内容覆盖回某个历史版本——这是一个**写入 / 不可逆**操作,且为**异步**:发起后立即返回受理标识,真正的回滚在后台进行,需通过状态查询轮询最终结果(进行中 / 成功 / 失败)。
|
||||
|
||||
`+undo` 是另一类撤销:撤销当前 CLI 用户在该电子表格里最近一次或最近多次由 AI 工具产生、且尚未撤销的写入。每个用户有独立 undo 栈;多人编辑同一篇文档时,只撤当前用户自己的 undo 栈,不撤其他用户的操作。它和历史版本回滚不同,不需要选择 `history_version_id`,也不是把整表恢复到某个历史快照。
|
||||
|
||||
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果;`+undo` 撤销当前用户最近的 AI 工具写入。若只是想拿**当前文档版本号(revision)**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量。
|
||||
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果。若只是想拿**当前文档版本号(revision)**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读取历史版本、发起回滚、查询回滚状态,或撤销当前用户最近一次 AI 工具写入。本 reference 覆盖 4 个 shortcut:
|
||||
读取历史版本、发起回滚、查询回滚状态。本 reference 覆盖 3 个 shortcut:
|
||||
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 查看历史版本列表 | `+history-list` | 返回 `minor_histories`,每条含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个字段;支持向前分页(可选 `--end-version`) |
|
||||
| 回滚到指定历史版本 | `+history-revert` | 传入 `--history-version-id`;异步受理,返回可查询标识 |
|
||||
| 查询回滚状态 | `+history-revert-status` | 传入 `--transaction-id`(取自 `+history-revert` 的异步受理标识);轮询某次回滚的进行中 / 成功 / 失败状态 |
|
||||
| 撤销自己最近的 AI 写入 | `+undo` | 只需 spreadsheet 定位;默认撤当前用户 undo 栈顶;可用 `--count` 连续撤销多步;支持普通单元格/结构写入,以及图表、透视表 update 的反向操作 |
|
||||
|
||||
典型工作流:`+history-list` 拿到目标版本的 `history_version_id`(必要时翻页拉取更早历史)→ `+history-revert` 发起回滚并取回 `transaction_id` → `+history-revert-status --transaction-id <transaction_id>` 轮询直到成功或失败。
|
||||
|
||||
`+undo` 的典型工作流更短:执行某个 AI 写入工具后,如果用户要求撤销刚才自己的修改,直接运行 `+undo`。返回 `undone=1` 表示已撤销一条当前用户栈顶;传 `--count N` 时会按当前用户栈顶从新到旧连续撤销,返回的 `undone` 是实际撤销数量。返回 `undone=0` 且 `reason=undo_stack_empty` 表示当前用户没有可撤销项。
|
||||
|
||||
**注意事项(必须了解)**:
|
||||
- **回滚是写入 / 不可逆操作**:会用历史版本内容覆盖当前表格,发起前请确认目标 `history_version_id` 正确。
|
||||
- **回滚是异步的**:`+history-revert` 返回的是 `transaction_id`(受理标识),不代表回滚已完成;必须用 `+history-revert-status --transaction-id <transaction_id>` 确认最终结果。
|
||||
- **`history_version_id` 与 `transaction_id` 不是同一个**:`history_version_id` 用于 `+history-revert`(取自 `+history-list`);`transaction_id` 用于 `+history-revert-status`(取自 `+history-revert` 的输出)。
|
||||
- **历史是工作簿级**:定位只需 `--url` / `--spreadsheet-token`(XOR),不需要子表选择器。
|
||||
- **`+history-list` 倒序分页**:首次查省略 `--end-version`,返回最新一页;若响应里附带 `next_end_version` 与 `has_more=true`,把 `next_end_version` 作为下一次的 `--end-version` 即可继续向更早翻页;当响应**不包含**这两个字段时表示已到最早一页,不必再翻。
|
||||
- **`+undo` 是用户维度**:只撤当前登录用户的 undo 栈顶;同一文档里其他用户的写入不会被撤销。
|
||||
- **`+undo --count` 是顺序多步撤销**:从当前用户最新栈项开始逐条撤销,最多 20 步;如果可撤销项不足或中途遇到不可撤销项,会停止并返回实际 `undone` 数量与 `reason`。
|
||||
- **`+undo` 不区分 session / agent**:同一用户的不同 CLI 会话或 agent 共用同一个用户 undo 栈。
|
||||
- **`+undo` 不进入 `+batch-update`**:撤销本身依赖用户栈状态,不能作为批量子操作嵌套执行。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -43,7 +34,6 @@
|
||||
| `+history-list` | read | 历史版本 |
|
||||
| `+history-revert` | write | 历史版本 |
|
||||
| `+history-revert-status` | read | 历史版本 |
|
||||
| `+undo` | write | 历史版本 |
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -71,17 +61,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
| --- | --- | --- | --- |
|
||||
| `--transaction-id` | string | required | 异步回滚的受理标识(取自 +history-revert) |
|
||||
|
||||
### `+undo`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--count` | int | optional | 连续撤销的用户 undo 栈项数量,默认 1,最大 20。按当前用户栈顶从新到旧顺序撤销,实际撤销数量以返回的 undone 为准。 |
|
||||
|
||||
## Examples
|
||||
|
||||
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`(XOR,二选一)。`+history-revert` 用 `--history-version-id`(取自 `+history-list`);`+history-revert-status` 用 `--transaction-id`(取自 `+history-revert` 的异步受理标识)。`+undo` 不需要 sheet-id,也不需要 history version;连续撤销多步时传 `--count`。
|
||||
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`(XOR,二选一)。`+history-revert` 用 `--history-version-id`(取自 `+history-list`);`+history-revert-status` 用 `--transaction-id`(取自 `+history-revert` 的异步受理标识)。
|
||||
|
||||
### `+history-list`
|
||||
|
||||
@@ -109,16 +91,3 @@ lark-cli sheets +history-revert --url "https://sample.feishu.cn/sheets/SHTxxxxxx
|
||||
# 查询某次回滚的当前状态(进行中 / 成功 / 失败)
|
||||
lark-cli sheets +history-revert-status --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --transaction-id "<transaction-id-from-history-revert>"
|
||||
```
|
||||
|
||||
### `+undo`
|
||||
|
||||
```bash
|
||||
# 撤销当前用户最近一次 AI 工具写入
|
||||
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx"
|
||||
|
||||
# 连续撤销当前用户最近 3 次 AI 工具写入;实际撤销数量以返回的 undone 为准
|
||||
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --count 3
|
||||
|
||||
# 先预览将调用的底层 undo_last 请求
|
||||
lark-cli sheets +undo --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --count 3 --dry-run
|
||||
```
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
5. **新增合并时数据保护**:合并前确认目标区域只有左上角有数据,其余单元格为空,否则合并会导致非左上角的数据丢失。
|
||||
6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-unmerge` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge,也不要用 `+batch-update` 拆成多次 unmerge。
|
||||
|
||||
**⚠️ 批量操作必须用 `+batch-update`**:对**多个**不同区域执行 `+cells-merge` 或 `+rows-resize / +cols-resize` 时,禁止逐个调用,合并为单次原子 `+batch-update`(语义与 `--operations` 入参格式见 `lark-sheets-batch-update`)。
|
||||
**⚠️ 批量操作必须用 `+batch-update`**:对**多个**不同区域执行 `+cells-merge` 时,禁止逐个调用,合并为单次原子 `+batch-update`(语义与 `--operations` 入参格式见 `lark-sheets-batch-update`)。行高列宽**不需要** `+batch-update`:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态,一次调用原子完成。
|
||||
|
||||
**唯一例外**:`+cells-unmerge` 原生支持传一个大 range 一次性取消其中所有合并区域,应直接单次调用,**不要**拆进 `+batch-update`。
|
||||
|
||||
@@ -128,9 +128,10 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--type` | string | required | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容)(可选值:`pixel` / `standard` / `auto`) |
|
||||
| `--size` | int | optional | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 |
|
||||
| `--range` | string | required | 要调整行高的行闭区间;1-based 行号如 `2:10` 或单行 `5` |
|
||||
| `--height` | int | xor | 统一行高(像素,例:30 / 40 / 60;不是磅/points),配 `--range` 使用。传了 `--height` 就是像素模式,可以省略 `--type`;显式 `--type pixel` 也行(等价)。多行不同高用 `--heights` |
|
||||
| `--heights` | string + File + Stdin(复合 JSON) | xor | 差异化行高 map,一次原子调用给多行设置不同高度:键为单行(`"1"`)或行闭区间(`"2:20"`),值为像素高(如 30 / 50)、`"auto"`(自适应内容)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是磅/points。与 `--range` / `--height` / `--type` 互斥 |
|
||||
| `--type` | string | xor | 尺寸方式 enum:`pixel`(需配 `--height`)/ `standard`(重置为默认行高)/ `auto`(自动适应内容)。常规写法直接给 `--height` 即可省略本 flag;`--type standard` / `--type auto` 不能与 `--height` 同时给(可选值:`pixel` / `standard` / `auto`) |
|
||||
| `--range` | string | xor | 要调整行高的行闭区间;1-based 行号如 `2:10` 或单行 `5`。统一尺寸形态必填(配 `--height` 或 `--type`);map 形态(`--heights`)不传 |
|
||||
|
||||
### `+cols-resize`
|
||||
|
||||
@@ -138,9 +139,10 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--type` | string | required | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽)(可选值:`pixel` / `standard`) |
|
||||
| `--size` | int | optional | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 |
|
||||
| `--range` | string | required | 要调整列宽的列闭区间;列字母如 `A:E` 或单列 `C` |
|
||||
| `--width` | int | xor | 统一列宽(像素,例:80 / 120 / 200;不是 Excel 字符单位),配 `--range` 使用。传了 `--width` 就是像素模式,可以省略 `--type`;显式 `--type pixel` 也行(等价)。多列不同宽用 `--widths` |
|
||||
| `--widths` | string + File + Stdin(复合 JSON) | xor | 差异化列宽 map,一次原子调用给多列设置不同宽度:键为单列(`"A"`)或列闭区间(`"C:E"`),值为像素宽(如 80 / 120 / 200)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是 Excel 字符单位(像素 ≈ 字符数×8+16)。与 `--range` / `--width` / `--type` 互斥 |
|
||||
| `--type` | string | xor | 尺寸方式 enum:`pixel`(需配 `--width`)/ `standard`(重置为默认列宽)。常规写法直接给 `--width` 即可省略本 flag;`--type standard` 不能与 `--width` 同时给(可选值:`pixel` / `standard`) |
|
||||
| `--range` | string | xor | 要调整列宽的列闭区间;列字母如 `A:E` 或单列 `C`。统一尺寸形态必填(配 `--width` 或 `--type`);map 形态(`--widths`)不传 |
|
||||
|
||||
### `+range-move`
|
||||
|
||||
@@ -187,6 +189,16 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+rows-resize` `--heights`
|
||||
|
||||
_行 → 高度 map_
|
||||
- type: object
|
||||
|
||||
### `+cols-resize` `--widths`
|
||||
|
||||
_列 → 宽度 map_
|
||||
- type: object
|
||||
|
||||
### `+range-sort` `--sort-keys`
|
||||
|
||||
_排序条件列表(仅 sort 操作)_
|
||||
@@ -227,14 +239,25 @@ lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100"
|
||||
|
||||
### `+rows-resize` / `+cols-resize`
|
||||
|
||||
行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。每条 `--type` 必填:
|
||||
行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。两种形态:
|
||||
|
||||
- **统一尺寸**:`--range` + `--height`/`--width <px>`(省略 `--type`,等价于 `--type pixel`)。非像素模式走 `--type standard` / `--type auto`,此时不能再带像素值。
|
||||
- **差异化尺寸**:`--heights`/`--widths` 一个 JSON map,键为单行/列或闭区间、值为像素或模式字符串,**一次调用原子完成多行 / 多列不同尺寸**——不要拆多次调用,也不要用 `+batch-update`。
|
||||
|
||||
```bash
|
||||
# 把第 2-10 行设为固定 30 px
|
||||
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --type pixel --size 30
|
||||
# 统一尺寸:把第 2-10 行设为固定 30 px
|
||||
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --height 30
|
||||
|
||||
# 把 A-C 列设为固定 120 px
|
||||
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --type pixel --size 120
|
||||
# 统一尺寸:把 A-C 列设为固定 120 px
|
||||
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --width 120
|
||||
|
||||
# 差异化尺寸:多列不同宽,一次调用(值可混用 "standard" 重置某列)
|
||||
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" \
|
||||
--widths '{"A": 100, "B": 358, "C:E": 120, "G": "standard"}'
|
||||
|
||||
# 差异化尺寸:多行不同高,值可混用 "auto" / "standard"
|
||||
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" \
|
||||
--heights '{"1": 50, "2:20": 30, "21": "auto"}'
|
||||
|
||||
# 第 1 行行高自动适应内容(列宽不支持 auto)
|
||||
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type auto
|
||||
@@ -243,6 +266,10 @@ lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type au
|
||||
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:E" --type standard
|
||||
```
|
||||
|
||||
**⚠️ 单位是像素,不是 Excel 字符单位 / 磅**:列宽常见 60~400px;如果你按 Excel 字符单位(openpyxl / xlsxwriter 的 `width`)心算,先换算 `px ≈ 字符数 × 8 + 16`——写 `{"A": 10}` 得到的是 10px 的不可用窄列(CLI 会拒绝 < 20px 的列宽并提示换算)。行高是像素不是磅(points),默认行高约 24px。
|
||||
|
||||
**列宽没有 auto-fit**:需要"列宽自适应内容"时,按"写入后列宽自适应"一节的公式估算像素值(`max(表头字符数, 内容最长字符数) × 8 + 16`)后用 `--widths` 显式设置。
|
||||
|
||||
> 同时出现在 `lark-sheets-sheet-structure.md` —— 行高 / 列宽调整也算行列结构层动作。
|
||||
|
||||
### `+range-move` / `+range-copy`
|
||||
@@ -265,6 +292,6 @@ lark-cli sheets +range-sort --url "..." --sheet-id "$SID" --range "A1:E100" --ha
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内;`+rows-resize` / `+cols-resize` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+cols-resize.--type` 不接受 `auto`(只行高支持自适应)。
|
||||
- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内;`+rows-resize` / `+cols-resize` 两种形态二选一——统一形态必须给 `--range` 且至少给 `--height`/`--width` 或 `--type` 之一(`--type standard`/`auto` 不能与像素 flag 同给,`--type pixel` 共存 OK),map 形态(`--heights`/`--widths`)不能与 `--range`/`--height`/`--width`/`--type` 混用,map 键必须与命令维度一致(行数字 / 列字母)、不得重复,值为正整数像素或模式字符串;列宽 < 20px 拒绝(疑似 Excel 字符单位);`+cols-resize` 不接受 `auto`(列宽不支持自适应)。map 形态在 `+batch-update` 子操作里不可用(它本身就是原子批量)。
|
||||
- `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <影响范围>` 抽样比对。
|
||||
|
||||
@@ -192,7 +192,7 @@ lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --t
|
||||
|
||||
> ⚠️ 这两条 shortcut 来自 `lark-sheets-range-operations` 的 `+rows-resize / +cols-resize` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `lark-sheets-range-operations.md`。
|
||||
>
|
||||
> 行 vs 列底层 schema 有差异:`+rows-resize.--type` 支持 `pixel` / `standard` / `auto`,`+cols-resize.--type` 只支持 `pixel` / `standard`(列宽不支持自动适应)。
|
||||
> 常规写法:行高走 `--range` + `--height <px>`、列宽走 `--range` + `--width <px>`,无需再传 `--type`(等价于 `--type pixel`);多行 / 多列不同尺寸用 map 形态 `--heights` / `--widths`(如 `--widths '{"A":100,"C:E":120}'`)一次原子完成,不要拆多次调用或走 `+batch-update`。`--type standard` / `--type auto` 用于非像素模式,不能与像素 flag 同给。`+cols-resize.--type` 不接受 `auto`(列宽不支持自动适应)。⚠️ 单位是像素(不是 Excel 字符单位 / 磅)。
|
||||
|
||||
### `+dim-freeze`
|
||||
|
||||
@@ -207,6 +207,6 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+rows-resize` / `+cols-resize` 的行 vs 列 `--type` 差异详见 `lark-sheets-range-operations.md`。
|
||||
- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `lark-sheets-range-operations.md`。
|
||||
- `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
- 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。
|
||||
- **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。
|
||||
- 具体采样与复制流程见下方「场景二:从已有区域继承美化」。
|
||||
- **列宽 / 行高调整**(飞书 `+rows-resize / +cols-resize` 按 pixel 传值):
|
||||
- **列宽 / 行高调整**(飞书 `+cols-resize` / `+rows-resize` 直接给像素值:统一尺寸用 `--range` + `--width`/`--height <px>`,多列 / 多行不同尺寸用 `--widths`/`--heights` map 一次原子完成,如 `--widths '{"A":100,"C:E":120}'`):
|
||||
- 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。
|
||||
- 经验估算:中文每字约 15-18px,英文/数字每字约 7-9px,外加 10-16px padding。
|
||||
- 上下限建议 80~400px;超上限启用自动换行(`word_wrap: auto-wrap`)+ 调整行高,而非无限加宽。
|
||||
@@ -155,7 +155,7 @@ Step 1 — 格式铺开:`+batch-update` + `+range-copy`(或 `+range-fill`)
|
||||
Step 2 — 内容覆写:`+batch-update` + `+cells-set`(仅传 value/formula,不传任何样式)
|
||||
└── 将每行的实际数据写入,cell_styles 全部省略,因为格式已在 Step 1 中就位
|
||||
|
||||
Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+cells-{merge|unmerge}` 等
|
||||
Step 3 — 微调收尾:`+rows-resize --heights` / `+cols-resize --widths`(行高列宽 map 一次原子完成)、`+batch-update` + `+cells-{merge|unmerge}` 等
|
||||
└── 调整行高列宽、处理合并单元格、扩展条件格式范围等边缘情况
|
||||
```
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
| `--index` | int | optional | 插入位置(0-based);省略时附加到末尾 |
|
||||
| `--row-count` | int | optional | 初始行数(默认 200,上限 50000) |
|
||||
| `--col-count` | int | optional | 初始列数(默认 20,上限 200) |
|
||||
| `--type` | string | optional | 新子表类型:sheet(电子表格)\| bitable(多维表格);默认 sheet。bitable 只建空表,内容编辑改用 lark-base 命令 |
|
||||
| `--type` | string | optional | 新子表类型:sheet(电子表格);默认 sheet。(可选值:`sheet`) |
|
||||
|
||||
### `+sheet-delete`
|
||||
|
||||
@@ -364,17 +364,8 @@ lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
|
||||
--title "汇总" --index 0
|
||||
```
|
||||
|
||||
新建一张**多维表格(bitable)子表**:加 `--type bitable`(默认 `sheet`,即普通电子表格子表)。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
|
||||
--title "任务表" --type bitable
|
||||
```
|
||||
|
||||
> 💡 `+sheet-create` 只建一张**空子表**。要在已有工作簿里建子表并一步写入 typed 数据和/或样式,用 `+table-put`(payload 里命名的子表缺则自动新建)配合它的 `--sheets` / `--styles`,省掉先建表再 `+cells-set` / `+cells-set-style` 的二次往返。
|
||||
|
||||
> 💡 `--type bitable` 只建一张**空的多维表格子表**(默认表 + 网格视图 + 默认字段)。它的内容编辑(字段、记录、视图)走 `lark-cli base`:先用 `+workbook-info` 拿到该子表的 `bitable_app_token` + `bitable_table_id`,再用 `lark-cli base +record-list` / `+record-create` 等操作;sheets 侧的网格类命令(`+cells-get` / `+cells-set` 等)对 bitable 子表会被拒。
|
||||
|
||||
### `+sheet-delete`
|
||||
|
||||
> ⚠️ 工作表删除不可逆;先 `--dry-run` 看输出 sheet_id + title 确认是要删的那张。
|
||||
|
||||
Reference in New Issue
Block a user