diff --git a/.golangci.yml b/.golangci.yml index f9a51eae8..d4b090bd9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -57,6 +57,14 @@ linters: - path: internal/vfs/ linters: - forbidigo + # internal/gen build-time generators (standalone `package main` run via + # go:generate) are not shortcut runtime code — no ctx/runtime/framework — + # so the shortcut forbidigo bans don't apply. Going "compliant" is also + # impossible here: a structured error return needs os.Exit (also banned), + # and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs. + - path: shortcuts/.*/internal/gen/ + linters: + - forbidigo # shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP # for the client / credential layer. - path-except: shortcuts/ diff --git a/cmd/build.go b/cmd/build.go index a748544b0..84828ed77 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -117,6 +117,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B installTipsHelpFunc(rootCmd) rootCmd.SilenceErrors = true + // SilenceUsage as a static field (not only in PersistentPreRun) so it also + // covers flag-parse errors, which fail before PreRun runs — otherwise cobra + // dumps usage instead of our structured error. SetFlagErrorFunc on root is + // inherited by every subcommand, turning unknown-flag errors into a + // structured "did you mean" envelope. + rootCmd.SilenceUsage = true + rootCmd.SetFlagErrorFunc(flagDidYouMean) RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals) rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { diff --git a/cmd/event/suggestions.go b/cmd/event/suggestions.go index f3275fda4..2bdaf5dff 100644 --- a/cmd/event/suggestions.go +++ b/cmd/event/suggestions.go @@ -10,6 +10,7 @@ import ( eventlib "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/suggest" ) const maxSuggestions = 3 @@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string { hits = append(hits, match{def.Key, 0}) continue } - if d := levenshtein(input, def.Key); d <= threshold { + if d := suggest.Levenshtein(input, def.Key); d <= threshold { hits = append(hits, match{def.Key, d}) } } @@ -69,34 +70,3 @@ func unknownEventKeyErr(key string) error { "Run 'lark-cli event list' to see available keys.", ) } - -// levenshtein computes classic edit distance (two-row DP). -func levenshtein(a, b string) int { - if a == b { - return 0 - } - ra, rb := []rune(a), []rune(b) - if len(ra) == 0 { - return len(rb) - } - if len(rb) == 0 { - return len(ra) - } - prev := make([]int, len(rb)+1) - curr := make([]int, len(rb)+1) - for j := range prev { - prev[j] = j - } - for i := 1; i <= len(ra); i++ { - curr[0] = i - for j := 1; j <= len(rb); j++ { - cost := 1 - if ra[i-1] == rb[j-1] { - cost = 0 - } - curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost) - } - prev, curr = curr, prev - } - return prev[len(rb)] -} diff --git a/cmd/event/suggestions_test.go b/cmd/event/suggestions_test.go index 0838fb623..fdaaa2c01 100644 --- a/cmd/event/suggestions_test.go +++ b/cmd/event/suggestions_test.go @@ -10,27 +10,6 @@ import ( _ "github.com/larksuite/cli/events" ) -func TestLevenshtein(t *testing.T) { - cases := []struct { - a, b string - want int - }{ - {"", "", 0}, - {"a", "", 1}, - {"", "abc", 3}, - {"kitten", "kitten", 0}, - {"kitten", "sitten", 1}, - {"kitten", "sitting", 3}, - {"飞书", "飞书", 0}, - {"飞书", "飞s", 1}, - } - for _, tc := range cases { - if got := levenshtein(tc.a, tc.b); got != tc.want { - t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want) - } - } -} - func TestSuggestEventKeys(t *testing.T) { cases := []struct { name string diff --git a/cmd/flag_suggest_test.go b/cmd/flag_suggest_test.go new file mode 100644 index 000000000..7adb35053 --- /dev/null +++ b/cmd/flag_suggest_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +func TestUnknownFlagName(t *testing.T) { + cases := []struct { + in string + name string + ok bool + }{ + {"unknown flag: --query", "query", 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 := unknownFlagName(errors.New(c.in)) + if name != c.name || ok != c.ok { + t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok) + } + } +} + +func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) { + c := &cobra.Command{Use: "demo"} + c.Flags().String("range", "", "") + c.Flags().String("find", "", "") + c.Flags().Bool("dry-run", false, "") + + err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail.Type != "unknown_flag" { + t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Hint, "--range") { + t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint) + } + detail, _ := exitErr.Detail.Detail.(map[string]any) + valid, _ := detail["valid_flags"].([]string) + if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") { + t.Errorf("valid_flags should list find & range, got %v", valid) + } +} + +func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) { + c := &cobra.Command{Use: "demo"} + err := flagDidYouMean(c, errors.New("flag needs an argument: --find")) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail.Type != "flag_error" { + t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type) + } +} diff --git a/cmd/notice_test.go b/cmd/notice_test.go new file mode 100644 index 000000000..51842ad8b --- /dev/null +++ b/cmd/notice_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/deprecation" +) + +// composePendingNotice must surface a deprecated-command alias under the +// "deprecated_command" key, with the migration target and a skill-update hint, +// so the JSON "_notice" envelope reaches users who run pre-refactor commands +// without ever reading --help. +func TestComposePendingNoticeDeprecatedCommand(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + + deprecation.SetPending(&deprecation.Notice{ + Command: "+read", + Replacement: "+cells-get", + Skill: "lark-sheets", + }) + + got := composePendingNotice() + if got == nil { + t.Fatal("composePendingNotice() = nil, want deprecated_command entry") + } + entry, ok := got["deprecated_command"].(map[string]interface{}) + if !ok { + t.Fatalf("missing deprecated_command key: %#v", got) + } + if entry["command"] != "+read" { + t.Errorf("command = %v, want +read", entry["command"]) + } + if entry["replacement"] != "+cells-get" { + t.Errorf("replacement = %v, want +cells-get", entry["replacement"]) + } + if entry["skill"] != "lark-sheets" { + t.Errorf("skill = %v, want lark-sheets", entry["skill"]) + } + if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") { + t.Errorf("message missing skill-update hint: %q", msg) + } +} + +// With nothing pending, the provider returns nil so no "_notice" field is +// emitted on a clean run. +func TestComposePendingNoticeEmpty(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + if got := composePendingNotice(); got != nil { + // update/skills pending are process-global; only assert the absence of + // our own key to stay robust against unrelated pending state. + if _, ok := got["deprecated_command"]; ok { + t.Fatalf("deprecated_command present after clear: %#v", got) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 11fdf0a5f..4cbb072bd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,14 +18,17 @@ import ( "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/skillscheck" + "github.com/larksuite/cli/internal/suggest" "github.com/larksuite/cli/internal/update" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) const rootLong = `lark-cli — Lark/Feishu CLI tool. @@ -69,7 +72,15 @@ COMMUNITY: More help: lark-cli --help` // Execute runs the root command and returns the process exit code. +// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's +// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags +// before they reach a group's RunE, so unknownSubcommandRunE re-derives them +// from here. It stays nil in unit tests that invoke a RunE directly with +// explicit args — correct, since those don't exercise the whitelist path. +var rawInvocationArgs []string + func Execute() int { + rawInvocationArgs = os.Args[1:] inv, err := BootstrapInvocationContext(os.Args[1:]) if err != nil { fmt.Fprintln(os.Stderr, "Error:", err) @@ -133,29 +144,49 @@ func setupNotices() { skillscheck.Init(build.Version) // Composed notice provider — emits keys only when each pending is set. - output.PendingNotice = func() map[string]interface{} { - notice := map[string]interface{}{} - if info := update.GetPending(); info != nil { - notice["update"] = map[string]interface{}{ - "current": info.Current, - "latest": info.Latest, - "message": info.Message(), - "command": "lark-cli update", - } + output.PendingNotice = composePendingNotice +} + +// composePendingNotice merges all process-level pending notices (available +// update, skills/binary drift, deprecated-command alias) into the map surfaced +// as the JSON "_notice" envelope field. Returns nil when nothing is pending. +// Extracted from Execute so the composition is unit-testable. +func composePendingNotice() map[string]interface{} { + notice := map[string]interface{}{} + if info := update.GetPending(); info != nil { + notice["update"] = map[string]interface{}{ + "current": info.Current, + "latest": info.Latest, + "message": info.Message(), + "command": "lark-cli update", } - if stale := skillscheck.GetPending(); stale != nil { - notice["skills"] = map[string]interface{}{ - "current": stale.Current, - "target": stale.Target, - "message": stale.Message(), - "command": "lark-cli update", - } + } + if stale := skillscheck.GetPending(); stale != nil { + notice["skills"] = map[string]interface{}{ + "current": stale.Current, + "target": stale.Target, + "message": stale.Message(), + "command": "lark-cli update", + } + } + if dep := deprecation.GetPending(); dep != nil { + entry := map[string]interface{}{ + "command": dep.Command, + "message": dep.Message(), + "action": "lark-cli update", } - if len(notice) == 0 { - return nil + if dep.Replacement != "" { + entry["replacement"] = dep.Replacement } - return notice + if dep.Skill != "" { + entry["skill"] = dep.Skill + } + notice["deprecated_command"] = entry + } + if len(notice) == 0 { + return nil } + return notice } // isCompletionCommand returns true if args indicate a shell completion request. @@ -260,6 +291,19 @@ func handleRootError(f *cmdutil.Factory, err error) int { return exitErr.Code } + // A backward-compat alias records its deprecation notice in PreRunE, which + // runs before cobra's required-flag validation — but a missing required flag + // fails before RunE and lands here, where the bare "Error:" line would drop + // the notice. When a deprecation is pending, route through the structured + // envelope so the migration hint still reaches the caller; all other errors + // keep the existing plain output. + if deprecation.GetPending() != nil { + output.WriteErrorEnvelope(errOut, &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "validation", Message: err.Error()}, + }, string(f.ResolvedIdentity)) + return 1 + } fmt.Fprintln(errOut, "Error:", err) return 1 } @@ -301,6 +345,12 @@ func asExitError(err error) *output.ExitError { func installUnknownSubcommandGuard(cmd *cobra.Command) { if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { cmd.RunE = unknownSubcommandRunE + // Route an unknown subcommand to unknownSubcommandRunE even when flags + // are also present (e.g. `sheets +cells-find --url ...`). A pure group + // consumes no flags itself, so unknown flags belong to the (missing) + // subcommand; whitelisting them here prevents cobra from erroring on the + // flag first and printing usage instead of our structured suggestion. + cmd.FParseErrWhitelist.UnknownFlags = true if cmd.Annotations == nil { cmd.Annotations = map[string]string{} } @@ -320,14 +370,89 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) { // they have moved to the typed surface. func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return cmd.Help() + // A bare group (e.g. `sheets`), or one carrying only group-valid flags + // like the global --profile, legitimately prints help. But a flag that + // belongs to a (missing) subcommand is a user error: the guard's + // FParseErrWhitelist swallows such flags and leaves args empty, so without + // the checks below they would silently fall through to help + exit 0 — + // letting an agent mistake a malformed call (`im --format json`, + // `sheets --badflag`) for success. Recover the swallowed tokens from the + // raw invocation and fail structured instead. + flags := flagTokensInArgs(rawInvocationArgs) + if len(flags) == 0 { + return cmd.Help() + } + if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 { + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "unknown_flag", + Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()), + Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()), + Detail: map[string]any{ + // Keep the same detail keys as flagDidYouMean's unknown_flag + // so a consumer keyed on Type can read a stable shape. The + // subcommand isn't resolved here, so suggestions/valid_flags + // have no meaningful universe to draw from — emit empty + // rather than the group's own (misleading) flags. unknown is + // the back-compat singular field; unknown_flags carries the + // full list when more than one flag was supplied. + "unknown": strings.Join(unknown, ", "), + "unknown_flags": unknown, + "command_path": cmd.CommandPath(), + "suggestions": []string{}, + "valid_flags": []string{}, + }, + }, + } + } + // The remaining flags are all defined somewhere in the tree. Those valid + // on the group itself or inherited (e.g. the global --profile) do not + // require a subcommand, so a bare group carrying only those still prints + // help. Anything left belongs to a subcommand that was omitted + // (e.g. `im --format json`): distinct from unknown_flag — the flags are + // real, the subcommand is what's missing. + misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs) + if len(misplaced) == 0 { + return cmd.Help() + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "missing_subcommand", + Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")), + Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()), + Detail: map[string]any{ + "command_path": cmd.CommandPath(), + "flags": misplaced, + "suggestions": []string{}, + }, + }, + } } unknown := args[0] - available := availableSubcommandNames(cmd) + available, deprecated := availableSubcommandNames(cmd) + // Rank suggestions across both current and deprecated names so a mistyped + // legacy command (e.g. +raed → +read) still resolves; the alias stays + // runnable and self-flags via the _notice on execution. + suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6) msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) - if len(available) > 0 { - hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", ")) + if len(suggestions) > 0 { + hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)", + strings.Join(suggestions, ", "), cmd.CommandPath()) + } + detail := map[string]any{ + "unknown": unknown, + "command_path": cmd.CommandPath(), + "suggestions": suggestions, + "available": available, + } + // Only services with backward-compat aliases (currently sheets) carry a + // deprecated bucket; omit the key elsewhere so every other service's + // envelope is unchanged. + if len(deprecated) > 0 { + detail["deprecated"] = deprecated } return &output.ExitError{ Code: output.ExitValidation, @@ -335,17 +460,114 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { Type: "unknown_subcommand", Message: msg, Hint: hint, - Detail: map[string]any{ - "unknown": unknown, - "command_path": cmd.CommandPath(), - "available": available, - }, + Detail: detail, }, } } -func availableSubcommandNames(cmd *cobra.Command) []string { - subs := make([]string, 0, len(cmd.Commands())) +// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in +// rawArgs, stopping at the "--" positional terminator. Whether a flag is +// defined is not considered (see unknownFlagTokens for that). A pure group +// with any flag token but no subcommand is a user error — a pure group +// consumes no flags of its own, so the flag must belong to a subcommand — so +// the caller fails structured instead of falling through to help. +func flagTokensInArgs(rawArgs []string) []string { + var toks []string + for _, a := range rawArgs { + if a == "--" { + break // everything after -- is positional + } + if len(a) < 2 || a[0] != '-' { + continue + } + toks = append(toks, a) + } + return toks +} + +// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define +// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard +// whitelists unknown flags on pure groups so a mistyped subcommand still reaches +// the suggestion path; the side effect is that flags before a subcommand are +// swallowed. This recovers the genuinely-unknown ones so the caller can name +// them in a "did you mean" envelope. +func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string { + var unknown []string + for _, a := range flagTokensInArgs(rawArgs) { + name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0] + if name != "" && !flagDefinedInTree(cmd, name) { + unknown = append(unknown, a) + } + } + return unknown +} + +// flagKnownOnGroup reports whether name is a flag defined on cmd itself or +// inherited (a global persistent flag like --profile) — i.e. valid on the bare +// group and therefore not requiring a subcommand. +func flagKnownOnGroup(cmd *cobra.Command, name string) bool { + short := len(name) == 1 + lookup := func(fs *pflag.FlagSet) bool { + if short { + return fs.ShorthandLookup(name) != nil + } + return fs.Lookup(name) != nil + } + return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags()) +} + +// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on +// a subcommand of cmd but not on cmd itself/inherited — flags supplied while +// omitting the subcommand they belong to (`im --format json`). Global flags +// valid on the bare group (e.g. --profile) are excluded so +// `lark-cli --profile p im` still prints help rather than erroring. +func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string { + var misplaced []string + for _, a := range flagTokensInArgs(rawArgs) { + name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0] + if name == "" || flagKnownOnGroup(cmd, name) { + continue + } + if flagDefinedInTree(cmd, name) { + misplaced = append(misplaced, a) + } + } + return misplaced +} + +// flagDefinedInTree reports whether name is defined on cmd, its inherited +// (persistent) flags, or any direct subcommand. The subcommand case covers a +// user who merely omitted the subcommand — e.g. `sheets --format json`, where +// --format is injected on every leaf shortcut, not on the group — so only a +// genuinely unknown flag like `sheets --badflag` is reported. +func flagDefinedInTree(cmd *cobra.Command, name string) bool { + short := len(name) == 1 + known := func(c *cobra.Command, inherited bool) bool { + fs := c.Flags() + if inherited { + fs = c.InheritedFlags() + } + if short { + return fs.ShorthandLookup(name) != nil + } + return fs.Lookup(name) != nil + } + if known(cmd, false) || known(cmd, true) { + return true + } + for _, c := range cmd.Commands() { + if known(c, false) { + return true + } + } + return false +} + +// availableSubcommandNames returns the invokable subcommand names of cmd, split +// into current commands and backward-compatibility aliases (those tagged into +// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are +// sorted; hidden commands plus help/completion are omitted. +func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) { for _, c := range cmd.Commands() { if c.Hidden || !c.IsAvailableCommand() { continue @@ -354,10 +576,95 @@ func availableSubcommandNames(cmd *cobra.Command) []string { if name == "help" || name == "completion" { continue } - subs = append(subs, name) + if cmdutil.IsDeprecatedCommand(c) { + deprecated = append(deprecated, name) + } else { + available = append(available, name) + } + } + sort.Strings(available) + sort.Strings(deprecated) + return available, deprecated +} + +// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It +// converts cobra's flag-parse errors into the structured ErrorEnvelope: an +// unknown flag gets a focused "did you mean" hint plus the full valid-flag list +// in detail (so agents recover even when the typo is semantic, e.g. --query vs +// --find, where edit distance alone finds nothing). Other flag errors stay +// structured but generic. +func flagDidYouMean(c *cobra.Command, ferr error) error { + name, isUnknown := unknownFlagName(ferr) + if !isUnknown { + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "flag_error", + Message: ferr.Error(), + Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()), + }, + } } - sort.Strings(subs) - return subs + valid := visibleFlagNames(c) + suggestions := suggest.Closest(name, valid, 3) + hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath()) + if len(suggestions) > 0 { + for i := range suggestions { + suggestions[i] = "--" + suggestions[i] + } + hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)", + strings.Join(suggestions, ", "), c.CommandPath()) + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "unknown_flag", + Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()), + Hint: hint, + Detail: map[string]any{ + "unknown": "--" + name, + "command_path": c.CommandPath(), + "suggestions": suggestions, + "valid_flags": valid, + }, + }, + } +} + +// unknownFlagName 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 the caller keeps +// those structured but generic — hallucinated flags are essentially always long. +// +// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod +// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match +// silently fails and unknown flags degrade to a generic flag_error — re-verify +// this prefix when bumping cobra. +func unknownFlagName(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 of c (for suggestions and +// the valid_flags detail). +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 } // installTipsHelpFunc wraps the default help function to append a TIPS section diff --git a/cmd/root_test.go b/cmd/root_test.go index 3ab78ceb2..fb1759e8f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -21,6 +21,7 @@ import ( internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" ) @@ -268,6 +269,54 @@ func (f *failingWriter) Write(p []byte) (int, error) { return len(p), nil } +// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a +// backward-compat alias that fails on a cobra-level required flag (which +// short-circuits before RunE) still routes through the structured envelope, +// because OnInvoke records the deprecation in PreRunE and the legacy fallback +// switches to WriteErrorEnvelope when a deprecation is pending — so the +// migration notice is no longer dropped on the plain "Error:" line. +func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Cleanup(func() { deprecation.SetPending(nil) }) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + deprecation.SetPending(&deprecation.Notice{ + Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets", + }) + // The bare error shape cobra's ValidateRequiredFlags produces: neither typed + // nor an *output.ExitError, so it reaches the legacy fallback. + handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) + + out := errOut.String() + if strings.HasPrefix(strings.TrimSpace(out), "Error:") { + t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out) + } + if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") { + t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out) + } +} + +// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no +// deprecation pending, the legacy fallback stays a plain "Error:" line, so the +// fix does not reshape every unrecognized cobra error. +func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + f, _, _, _ := cmdutil.TestFactory(t, nil) + errOut := &bytes.Buffer{} + f.IOStreams.ErrOut = errOut + + handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) + if !strings.HasPrefix(errOut.String(), "Error:") { + t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String()) + } +} + // TestHandleRootError_PartialWritePreservesExitCode pins that when the // stderr write fails mid-envelope, handleRootError still returns the typed // exit code (ExitAuth=3 for AuthenticationError), not fall through to the diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 4bba607d5..6bff7e7e4 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" ) @@ -72,6 +73,149 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) { } } +func TestUnknownFlagTokens(t *testing.T) { + _, drive, _ := newGroupTree() + // Give a subcommand a flag so a misplaced-but-known flag (the user omitted + // the subcommand) is distinguished from a genuinely unknown one. + for _, c := range drive.Commands() { + if c.Name() == "+search" { + c.Flags().String("query", "", "") + } + } + cases := []struct { + name string + rawArgs []string + want []string + }{ + {"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}}, + {"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil}, + {"no flags at all", []string{"drive"}, nil}, + {"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil}, + {"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := unknownFlagTokens(drive, tc.rawArgs) + if len(got) != len(tc.want) { + t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) { + _, drive, _ := newGroupTree() + installUnknownSubcommandGuard(drive.Root()) + + // Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows + // --badflag, so RunE sees no args; the guard must recover it from + // rawInvocationArgs and fail structured rather than print help + exit 0. + rawInvocationArgs = []string{"drive", "--badflag"} + t.Cleanup(func() { rawInvocationArgs = nil }) + + err := drive.RunE(drive, nil) + if err == nil { + t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)") + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Errorf("error = %q, want it to mention an unknown flag", err.Error()) + } + + // The detail must stay schema-compatible with flagDidYouMean's unknown_flag + // (same Type → same keys), so a consumer keyed on Type reads a stable shape. + exitErr, ok := err.(*output.ExitError) + if !ok || exitErr.Detail == nil { + t.Fatalf("expected *output.ExitError with Detail, got %T", err) + } + if exitErr.Detail.Type != "unknown_flag" { + t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type) + } + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail) + } + if detail["unknown"] != "--badflag" { + t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"]) + } + if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" { + t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"]) + } + for _, key := range []string{"suggestions", "valid_flags"} { + if _, present := detail[key]; !present { + t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key) + } + } +} + +func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) { + _, drive, _ := newGroupTree() + // --query is defined on the +search subcommand, so it is a *valid* flag that + // was placed before the (omitted) subcommand. Unlike an unknown flag, this + // must still fail structured (missing_subcommand) rather than fall through to + // help + exit 0 — `drive --query x` is a malformed call, not a help request. + for _, c := range drive.Commands() { + if c.Name() == "+search" { + c.Flags().String("query", "", "") + } + } + installUnknownSubcommandGuard(drive.Root()) + + rawInvocationArgs = []string{"drive", "--query", "x"} + t.Cleanup(func() { rawInvocationArgs = nil }) + + err := drive.RunE(drive, nil) + if err == nil { + t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" { + t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail) + } + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail) + } + if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" { + t.Errorf("detail.flags = %v, want [--query]", detail["flags"]) + } + if detail["command_path"] != "lark-cli drive" { + t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"]) + } +} + +// A bare group carrying only a group-valid global flag (e.g. the inherited +// --profile) is not missing a subcommand — those flags do not belong to a +// subcommand — so it must print help, not fail with missing_subcommand. +func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) { + _, drive, _ := newGroupTree() + drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive + installUnknownSubcommandGuard(drive.Root()) + + rawInvocationArgs = []string{"--profile", "p", "drive"} + t.Cleanup(func() { rawInvocationArgs = nil }) + + var buf bytes.Buffer + drive.SetOut(&buf) + drive.SetErr(&buf) + if err := drive.RunE(drive, nil); err != nil { + t.Fatalf("bare group with only a global flag should print help, got error: %v", err) + } + if !strings.Contains(buf.String(), "drive ops") { + t.Errorf("expected help output, got:\n%s", buf.String()) + } +} + func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) { _, drive, _ := newGroupTree() installUnknownSubcommandGuard(drive.Root()) @@ -113,11 +257,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) { if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) { t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message) } - if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") { - t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint) - } - if strings.Contains(exitErr.Detail.Hint, "+secret") { - t.Error("hidden commands must not appear in the hint") + // "+bogus" has no close neighbor among drive's subcommands, so the hint falls + // back to pointing at --help; the full machine-readable list lives in + // detail.available below (which also excludes hidden commands). + if !strings.Contains(exitErr.Detail.Hint, "--help") { + t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint) } detail, ok := exitErr.Detail.Detail.(map[string]any) @@ -164,7 +308,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) { &cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }}, ) - got := availableSubcommandNames(root) + got, _ := availableSubcommandNames(root) want := []string{"alpha", "gamma"} if len(got) != len(want) { t.Fatalf("expected %v, got %v", want, got) @@ -175,3 +319,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) { } } } + +func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"}) + root.AddCommand( + &cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }}, + ) + + available, deprecated := availableSubcommandNames(root) + if len(available) != 1 || available[0] != "+new-cmd" { + t.Errorf("available = %v, want [+new-cmd]", available) + } + if len(deprecated) != 1 || deprecated[0] != "+old-cmd" { + t.Errorf("deprecated = %v, want [+old-cmd]", deprecated) + } +} + +// unknownSubcommandRunE must split current vs deprecated subcommands into +// separate detail buckets, while suggestions still rank across both so a +// mistyped legacy alias resolves. +func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) { + svc := &cobra.Command{Use: "sheets"} + svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"}) + svc.AddCommand( + &cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }}, + ) + + err := unknownSubcommandRunE(svc, []string{"+reat"}) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail) + } + + if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" { + t.Errorf("available = %v, want [+cells-get]", available) + } + deprecated, ok := detail["deprecated"].([]string) + if !ok || len(deprecated) != 1 || deprecated[0] != "+read" { + t.Errorf("deprecated = %v, want [+read]", deprecated) + } + // suggestions rank across both buckets: "+reat" is closest to +read. + suggestions, _ := detail["suggestions"].([]string) + found := false + for _, s := range suggestions { + if s == "+read" { + found = true + } + } + if !found { + t.Errorf("suggestions %v should include +read (typo target)", suggestions) + } +} diff --git a/go.mod b/go.mod index 1ee4b73cc..8c76bd7d7 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/sergi/go-diff v1.4.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 - github.com/spf13/cobra v1.10.2 + github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 diff --git a/internal/cmdpolicy/suggest.go b/internal/cmdpolicy/suggest.go index 2f7362e31..ea2ae5979 100644 --- a/internal/cmdpolicy/suggest.go +++ b/internal/cmdpolicy/suggest.go @@ -5,6 +5,7 @@ package cmdpolicy import ( "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/suggest" ) // suggestRisk returns the closest valid Risk literal by edit distance @@ -20,9 +21,9 @@ func suggestRisk(bad string) string { platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite, } best := string(candidates[0]) - bestDist := levenshtein(lowered, best) + bestDist := suggest.Levenshtein(lowered, best) for _, c := range candidates[1:] { - if d := levenshtein(lowered, string(c)); d < bestDist { + if d := suggest.Levenshtein(lowered, string(c)); d < bestDist { bestDist, best = d, string(c) } } @@ -40,47 +41,3 @@ func toLower(s string) string { } return string(b) } - -// levenshtein computes the classic edit distance between two strings. -// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set -// makes raw performance irrelevant — clarity beats trickiness here. -func levenshtein(a, b string) int { - if len(a) == 0 { - return len(b) - } - if len(b) == 0 { - return len(a) - } - prev := make([]int, len(b)+1) - curr := make([]int, len(b)+1) - for j := 0; j <= len(b); j++ { - prev[j] = j - } - for i := 1; i <= len(a); i++ { - curr[0] = i - for j := 1; j <= len(b); j++ { - cost := 1 - if a[i-1] == b[j-1] { - cost = 0 - } - curr[j] = min3( - prev[j]+1, // deletion - curr[j-1]+1, // insertion - prev[j-1]+cost, // substitution - ) - } - prev, curr = curr, prev - } - return prev[len(b)] -} - -func min3(a, b, c int) int { - m := a - if b < m { - m = b - } - if c < m { - m = c - } - return m -} diff --git a/internal/cmdpolicy/suggest_test.go b/internal/cmdpolicy/suggest_test.go index da91495a2..e8aae8e6c 100644 --- a/internal/cmdpolicy/suggest_test.go +++ b/internal/cmdpolicy/suggest_test.go @@ -29,23 +29,3 @@ func TestSuggestRisk(t *testing.T) { } } } - -func TestLevenshtein(t *testing.T) { - cases := []struct { - a, b string - want int - }{ - {"", "", 0}, - {"", "abc", 3}, - {"abc", "", 3}, - {"abc", "abc", 0}, - {"wrtie", "write", 2}, - {"kitten", "sitting", 3}, - } - for _, c := range cases { - got := levenshtein(c.a, c.b) - if got != c.want { - t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want) - } - } -} diff --git a/internal/cmdutil/groups.go b/internal/cmdutil/groups.go new file mode 100644 index 000000000..5045f555b --- /dev/null +++ b/internal/cmdutil/groups.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "github.com/spf13/cobra" + +// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility +// command — one kept alive for users whose skill predates a refactor. Service +// registration assigns it (e.g. the sheets pre-refactor aliases); both --help +// rendering and unknown-subcommand suggestions read it to separate these +// aliases from the current commands. +const DeprecatedGroupID = "deprecated" + +// IsDeprecatedCommand reports whether c was tagged into the deprecated group. +func IsDeprecatedCommand(c *cobra.Command) bool { + return c != nil && c.GroupID == DeprecatedGroupID +} diff --git a/internal/deprecation/deprecation.go b/internal/deprecation/deprecation.go new file mode 100644 index 000000000..ad5b4be5b --- /dev/null +++ b/internal/deprecation/deprecation.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package deprecation carries a process-level notice that the command currently +// being executed is a backward-compatibility alias, kept alive for users whose +// skill predates a refactor. The notice is surfaced in JSON output envelopes via +// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck. +// +// A CLI process runs exactly one shortcut, so a single process-level slot is +// sufficient: the command's Execute records the notice before producing output, +// and the output layer reads it back when building the envelope. +package deprecation + +import ( + "strings" + "sync/atomic" +) + +// Notice describes a deprecated command alias and the current command that +// replaces it. Replacement and Skill are optional. +type Notice struct { + Command string `json:"command"` + Replacement string `json:"replacement,omitempty"` + Skill string `json:"skill,omitempty"` +} + +// Message returns a single-line, AI-agent-parseable description of the alias +// plus the canonical fix (update the skill). Mirrors the style of +// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update"). +func (n *Notice) Message() string { + var b strings.Builder + b.WriteString(n.Command) + b.WriteString(" is a pre-refactor compatibility alias") + if n.Replacement != "" { + b.WriteString("; use ") + b.WriteString(n.Replacement) + b.WriteString(" instead") + } + if n.Skill != "" { + b.WriteString("; update your ") + b.WriteString(n.Skill) + b.WriteString(" skill, run: lark-cli update") + } else { + b.WriteString("; update your skill, run: lark-cli update") + } + return b.String() +} + +// pending stores the latest deprecation notice for the current process. +var pending atomic.Pointer[Notice] + +// SetPending stores the notice for consumption by output decorators. +// Pass nil to clear. +func SetPending(n *Notice) { pending.Store(n) } + +// GetPending returns the pending deprecation notice, or nil. +func GetPending() *Notice { return pending.Load() } diff --git a/internal/deprecation/deprecation_test.go b/internal/deprecation/deprecation_test.go new file mode 100644 index 000000000..69237c9f4 --- /dev/null +++ b/internal/deprecation/deprecation_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deprecation + +import "testing" + +func TestNoticeMessage(t *testing.T) { + tests := []struct { + name string + notice Notice + want string + }{ + { + name: "replacement and skill", + notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"}, + want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update", + }, + { + name: "no replacement", + notice: Notice{Command: "+read", Skill: "lark-sheets"}, + want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update", + }, + { + name: "no skill", + notice: Notice{Command: "+read", Replacement: "+cells-get"}, + want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.notice.Message(); got != tt.want { + t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want) + } + }) + } +} + +func TestSetGetPending(t *testing.T) { + t.Cleanup(func() { SetPending(nil) }) + + SetPending(nil) + if got := GetPending(); got != nil { + t.Fatalf("expected nil pending after clear, got %#v", got) + } + + n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"} + SetPending(n) + got := GetPending() + if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" { + t.Fatalf("GetPending() = %#v, want %#v", got, n) + } + + SetPending(nil) + if GetPending() != nil { + t.Fatal("expected nil after clearing") + } +} diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go new file mode 100644 index 000000000..fe4471f6b --- /dev/null +++ b/internal/suggest/suggest.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package suggest provides the shared "did you mean" primitives: a rune-aware +// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the +// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each +// carrying their own copy. +package suggest + +import "sort" + +// Levenshtein computes the classic edit distance between two strings. It is +// rune-aware, so it is correct for multi-byte input. +func Levenshtein(a, b string) int { + if a == b { + return 0 + } + ra, rb := []rune(a), []rune(b) + if len(ra) == 0 { + return len(rb) + } + if len(rb) == 0 { + return len(ra) + } + prev := make([]int, len(rb)+1) + curr := make([]int, len(rb)+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= len(ra); i++ { + curr[0] = i + for j := 1; j <= len(rb); j++ { + cost := 1 + if ra[i-1] == rb[j-1] { + cost = 0 + } + curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost) + } + prev, curr = curr, prev + } + return prev[len(rb)] +} + +// Closest returns up to maxN of candidates that plausibly match typed, ranked +// by shared-prefix length (desc) then edit distance (asc), keeping only +// reasonably-close ones. +// +// Shared prefix is weighted first on purpose: hallucinated names are often +// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search", +// "--with-styles" vs nothing close), where the common prefix is the strongest +// signal of intent that raw edit distance misses. +func Closest(typed string, candidates []string, maxN int) []string { + type scored struct { + name string + prefix int + dist int + } + limit := editLimit(typed) + ranked := make([]scored, 0, len(candidates)) + for _, c := range candidates { + p := sharedPrefixLen(typed, c) + d := Levenshtein(typed, c) + // Keep only plausible matches: a meaningful shared prefix, or an edit + // distance within budget. Drop everything else so the hint stays short. + if p >= 3 || d <= limit { + ranked = append(ranked, scored{name: c, prefix: p, dist: d}) + } + } + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].prefix != ranked[j].prefix { + return ranked[i].prefix > ranked[j].prefix + } + if ranked[i].dist != ranked[j].dist { + return ranked[i].dist < ranked[j].dist + } + return ranked[i].name < ranked[j].name + }) + if maxN <= 0 || maxN > len(ranked) { + maxN = len(ranked) + } + out := make([]string, 0, maxN) + for _, s := range ranked[:maxN] { + out = append(out, s.name) + } + return out +} + +// editLimit allows roughly one third of the typed length in edits (min 2), so +// short names tolerate a couple of typos and longer ones proportionally more. +func editLimit(s string) int { + if l := len([]rune(s)) / 3; l > 2 { + return l + } + return 2 +} + +func sharedPrefixLen(a, b string) int { + ra, rb := []rune(a), []rune(b) + n := 0 + for n < len(ra) && n < len(rb) && ra[n] == rb[n] { + n++ + } + return n +} diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go new file mode 100644 index 000000000..dcf194349 --- /dev/null +++ b/internal/suggest/suggest_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package suggest + +import ( + "slices" + "testing" +) + +func TestClosest_HallucinatedSharesPrefix(t *testing.T) { + cmds := []string{ + "+cells-get", "+cells-set", "+cells-search", "+cells-replace", + "+cells-clear", "+cells-merge", "+csv-get", "+chart-create", + "+pivot-create", "+sheet-info", + } + // "+cells-find" is semantically +cells-search but lexically far; the shared + // "+cells-" prefix should still surface the right family (incl. +cells-search). + got := Closest("+cells-find", cmds, 6) + if len(got) == 0 || len(got) > 6 { + t.Fatalf("expected 1..6 suggestions, got %v", got) + } + if !slices.Contains(got, "+cells-search") { + t.Errorf("expected +cells-search among suggestions, got %v", got) + } + for _, s := range got { + if len(s) < 7 || s[:7] != "+cells-" { + t.Errorf("suggestion %q does not share the +cells- prefix", s) + } + } +} + +func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) { + got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3) + if len(got) == 0 || got[0] != "+cells-get" { + t.Errorf("expected +cells-get first for typo +cell-get, got %v", got) + } +} + +func TestClosest_NoPlausibleMatch(t *testing.T) { + if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 { + t.Errorf("expected no suggestions for unrelated input, got %v", got) + } +} + +func TestLevenshtein(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"", "abc", 3}, + {"abc", "", 3}, + {"abc", "abc", 0}, + {"kitten", "sitting", 3}, + {"cell-get", "cells-get", 1}, + {"--query", "--find", 5}, + {"飞书", "飞书", 0}, // rune-aware: multi-byte equal + {"飞书", "飞s", 1}, // one rune substitution, not byte count + } + for _, c := range cases { + if d := Levenshtein(c.a, c.b); d != c.want { + t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want) + } + } +} + +func TestSharedPrefixLen(t *testing.T) { + if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 { + t.Errorf("sharedPrefixLen = %d, want 7", got) + } + if got := sharedPrefixLen("abc", "xyz"); got != 0 { + t.Errorf("sharedPrefixLen = %d, want 0", got) + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 4c533896e..a7772a3bd 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -30,6 +30,7 @@ import ( "github.com/larksuite/cli/internal/i18n" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // RuntimeContext provides helpers for shortcut execution. @@ -72,6 +73,16 @@ func (ctx *RuntimeContext) IsBot() bool { return ctx.As().IsBot() } +// Command returns the shortcut command name as cobra knows it (e.g. +// "+pivot-create"). Used by per-service helpers (e.g. sheets schema +// validation) that key off the shortcut identity. +func (ctx *RuntimeContext) Command() string { + if ctx.Cmd == nil { + return "" + } + return ctx.Cmd.Name() +} + // UserOpenId returns the current user's open_id from config. func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId } @@ -200,6 +211,12 @@ func (ctx *RuntimeContext) Int(name string) int { return v } +// Float64 returns a float64 flag value (non-integer numbers). +func (ctx *RuntimeContext) Float64(name string) float64 { + v, _ := ctx.Cmd.Flags().GetFloat64(name) + return v +} + // StrArray returns a string-array flag value (repeated flag, no CSV splitting). func (ctx *RuntimeContext) StrArray(name string) []string { v, _ := ctx.Cmd.Flags().GetStringArray(name) @@ -938,6 +955,29 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f return runShortcut(cmd, f, &shortcut, botOnly) }, } + if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil { + onInvoke := shortcut.OnInvoke + relaxRequiredForSchema := shortcut.PrintFlagSchema != nil + // PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses: + // - OnInvoke: fire a side effect (e.g. a deprecation notice) that must + // surface even when the call later fails on a missing required flag. + // - --print-schema: pure local introspection; relax the required-flag + // gate so callers don't fill in unrelated flags just to ask for a + // schema (clearing the annotation here is the supported opt-out). + cmd.PreRunE = func(c *cobra.Command, _ []string) error { + if onInvoke != nil { + onInvoke() + } + if relaxRequiredForSchema { + if want, _ := c.Flags().GetBool("print-schema"); want { + c.Flags().VisitAll(func(fl *pflag.Flag) { + delete(fl.Annotations, cobra.BashCompOneRequiredFlag) + }) + } + } + return nil + } + } cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes) registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut) cmdutil.SetTips(cmd, shortcut.Tips) @@ -951,6 +991,31 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f // runShortcut is the execution pipeline for a declarative shortcut. // Each step is a clear phase: identity → config → scopes → context → validate → execute. func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { + // --print-schema short-circuits everything below: it's pure local + // introspection, no identity / scope / network needed. The flag is + // only registered when the shortcut opts in via PrintFlagSchema. + if s.PrintFlagSchema != nil { + if want, _ := cmd.Flags().GetBool("print-schema"); want { + flagName, _ := cmd.Flags().GetString("flag-name") + out, err := s.PrintFlagSchema(strings.TrimSpace(flagName)) + if err != nil { + // PrintFlagSchema implementations return bare errors; wrap as a + // structured ExitError so --print-schema (an agent-facing + // introspection path) yields a parseable envelope, not a plain + // string. + if _, ok := err.(*output.ExitError); !ok { + err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error()) + } + return err + } + if len(out) == 0 { + return nil + } + fmt.Fprintln(f.IOStreams.Out, string(out)) + return nil + } + } + as, err := resolveShortcutIdentity(cmd, f, s) if err != nil { return err @@ -1055,6 +1120,16 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf return rctx, nil } +// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a +// file or stdin. A BOM that survives into a CSV cell corrupts the first value +// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the +// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'". +// Some editors and exporters add it silently. Only a leading BOM is removed; interior +// occurrences are left untouched. +func stripUTF8BOM(s string) string { + return strings.TrimPrefix(s, "\uFEFF") +} + // resolveInputFlags resolves @file and - (stdin) for flags with Input sources. // Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content. func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { @@ -1089,7 +1164,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { WithParam("--" + fl.Name). WithCause(err) } - rctx.Cmd.Flags().Set(fl.Name, string(data)) + // strip a leading UTF-8 BOM so it can't corrupt the first CSV + // cell or break JSON parsing downstream. + rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data))) continue } @@ -1116,7 +1193,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { WithParam("--" + fl.Name). WithCause(err) } - rctx.Cmd.Flags().Set(fl.Name, string(data)) + // strip a leading UTF-8 BOM so it + // can't corrupt the first CSV cell or break JSON parsing downstream. + rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data))) continue } } @@ -1203,6 +1282,10 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f var d int fmt.Sscanf(fl.Default, "%d", &d) cmd.Flags().Int(fl.Name, d, desc) + case "float64": + var d float64 + fmt.Sscanf(fl.Default, "%g", &d) + cmd.Flags().Float64(fl.Name, d, desc) case "string_array": cmd.Flags().StringArray(fl.Name, nil, desc) case "string_slice": @@ -1237,6 +1320,17 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f if s.Risk == "high-risk-write" { cmd.Flags().Bool("yes", false, "confirm high-risk operation") } + if s.PrintFlagSchema != nil { + // Guard against a shortcut that already declares these reserved + // introspection flags: pflag panics on a duplicate registration. + // Mirrors the Lookup guard on --format above. + if cmd.Flags().Lookup("print-schema") == nil { + cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing") + } + if cmd.Flags().Lookup("flag-name") == nil { + cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema") + } + } cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes) } diff --git a/shortcuts/common/runner_flag_completion_test.go b/shortcuts/common/runner_flag_completion_test.go index c0eaf2807..49da9a275 100644 --- a/shortcuts/common/runner_flag_completion_test.go +++ b/shortcuts/common/runner_flag_completion_test.go @@ -97,6 +97,46 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) { } } +// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved +// --print-schema / --flag-name flags are registered defensively: a shortcut +// that already declares same-named flags must not trigger pflag's duplicate- +// registration panic (the Lookup guard in registerShortcutFlagsWithContext). +func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + parent := &cobra.Command{Use: "root"} + shortcut := Shortcut{ + Service: "docs", + Command: "+introspect", + Description: "x", + // The shortcut's own flags collide with the names the runner auto- + // injects when PrintFlagSchema is set. Without the guard, pflag panics. + Flags: []Flag{ + {Name: "print-schema", Desc: "user-defined collision"}, + {Name: "flag-name", Desc: "user-defined collision"}, + }, + PrintFlagSchema: func(string) ([]byte, error) { return nil, nil }, + Execute: func(context.Context, *RuntimeContext) error { return nil }, + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r) + } + }() + shortcut.Mount(parent, f) + + cmd, _, err := parent.Find([]string{"+introspect"}) + if err != nil { + t.Fatalf("Find() error = %v", err) + } + if cmd.Flags().Lookup("print-schema") == nil { + t.Error("print-schema flag should still exist after the guarded registration") + } + if cmd.Flags().Lookup("flag-name") == nil { + t.Error("flag-name flag should still exist after the guarded registration") + } +} + func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) parent := &cobra.Command{Use: "root"} diff --git a/shortcuts/common/runner_input_test.go b/shortcuts/common/runner_input_test.go index 1d44023a7..1b46dda1e 100644 --- a/shortcuts/common/runner_input_test.go +++ b/shortcuts/common/runner_input_test.go @@ -221,3 +221,53 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +func TestStripUTF8BOM(t *testing.T) { + cases := []struct{ name, in, want string }{ + {"leading BOM removed", "\uFEFFhello", "hello"}, + {"no BOM unchanged", "hello", "hello"}, + {"empty unchanged", "", ""}, + {"only BOM becomes empty", "\uFEFF", ""}, + {"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"}, + {"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"}, + } + for _, c := range cases { + if got := stripUTF8BOM(c.in); got != c.want { + t.Errorf("%s: stripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want) + } + } +} + +func TestResolveInputFlags_StripBOMStdin(t *testing.T) { + // A CSV piped via stdin with a leading BOM (e.g. from an upstream export) + // must reach the shortcut without the BOM, so it can't corrupt the first cell. + rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8") + flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}} + + if err := resolveInputFlags(rctx, flags); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := rctx.Str("csv"); got != "name,age\nzhang,8" { + t.Errorf("leading BOM not stripped from stdin, got %q", got) + } +} + +func TestResolveInputFlags_StripBOMFile(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + // A JSON operations file saved with a BOM would otherwise fail json.Unmarshal + // with "invalid character 'ï'". + if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil { + t.Fatal(err) + } + rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "") + flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}} + + if err := resolveInputFlags(rctx, flags); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" { + t.Errorf("leading BOM not stripped from file, got %q", got) + } +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index bf3f0307b..86eb80b74 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -18,7 +18,7 @@ const ( // Flag describes a CLI flag for a shortcut. type Flag struct { Name string // flag name (e.g. "calendar-id") - Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice" + Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice" Default string // default value as string Desc string // help text Hidden bool // hidden from --help, still readable at runtime @@ -58,6 +58,29 @@ type Shortcut struct { Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic + // OnInvoke, when non-nil, runs from the command's cobra PreRunE — before + // cobra validates required flags — so its side effect fires even when the + // call later fails on a missing required flag (which short-circuits before + // Validate/Execute). The backward-compat aliases use it to record a + // deprecation notice that must surface regardless of whether the call + // validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending). + OnInvoke func() + + // PrintFlagSchema, when non-nil, opts this shortcut into the + // `--print-schema --flag-name ` runtime introspection contract. + // The framework auto-injects those two system flags and short-circuits + // Validate/Execute when --print-schema is set, dispatching to this hook. + // + // Contract: + // - flagName == "" → list the flags this shortcut can describe + // (output is impl-defined; agents read this to + // discover which flags are introspectable). + // - flagName == "...": → return the JSON Schema (or schema-like blob) + // for that flag. + // Return value is written to stdout verbatim; callers typically format + // it as JSON. Returning an error surfaces as a normal command error. + PrintFlagSchema func(flagName string) ([]byte, error) + // PostMount is an optional hook called after the cobra.Command is fully // configured (flags registered, tips set) and after parent.AddCommand(cmd) // has attached it to the parent. Use it to install custom help functions or diff --git a/shortcuts/register.go b/shortcuts/register.go index 395990285..8efa979a8 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/apps" @@ -29,6 +30,7 @@ import ( "github.com/larksuite/cli/shortcuts/markdown" "github.com/larksuite/cli/shortcuts/minutes" "github.com/larksuite/cli/shortcuts/sheets" + sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward" "github.com/larksuite/cli/shortcuts/slides" "github.com/larksuite/cli/shortcuts/task" "github.com/larksuite/cli/shortcuts/vc" @@ -64,6 +66,11 @@ func init() { allShortcuts = append(allShortcuts, im.Shortcuts()...) allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...) allShortcuts = append(allShortcuts, sheets.Shortcuts()...) + // Backward-compatible sheets shortcuts (pre-refactor command names), + // kept under shortcuts/sheets/backward so external callers relying on the + // old `+create`, `+read`, `+write`, ... commands keep working alongside the + // refactored ones. Command names are disjoint from sheets.Shortcuts(). + allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...) allShortcuts = append(allShortcuts, base.Shortcuts()...) allShortcuts = append(allShortcuts, event.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) @@ -146,6 +153,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f if service == "mail" { mail.InstallOnMail(svc) } + if service == "sheets" { + applySheetsCompatGroups(svc) + } if !IsShortcutServiceAvailable(service, brand) { installBrandRestrictionGuard(svc, service, brand) @@ -189,3 +199,153 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core // --help bypasses RunE, so surface the restriction in Long too. svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand) } + +// Sheets backward-compatibility help grouping. +// +// shortcuts/sheets/backward keeps the pre-refactor command names alive so that +// users whose lark-sheets skill predates the refactor keep working even after +// upgrading only the binary. In `sheets --help` those aliases would otherwise +// sort alphabetically into the same flat list as the current commands, +// indistinguishable from them. applySheetsCompatGroups splits them into a +// dedicated cobra group whose heading tells the user to update their skill, and +// appends a "(→ +new-command)" pointer to each alias so the migration target is +// obvious. Pure presentation — the aliases stay fully executable. +const ( + sheetsCurrentGroupID = "sheets-current" + // sheetsDeprecatedGroupID aliases the shared deprecated-group id so both + // `sheets --help` grouping and the generic unknown-subcommand path + // (cmd/root.go) classify these aliases the same way. + sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID +) + +// sheetsAliasReplacement maps each pre-refactor sheets alias to the current +// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases +// absent from this map still land in the deprecated group, just without a +// pointer, so a missing entry degrades gracefully rather than misgrouping. +var sheetsAliasReplacement = map[string]string{ + // spreadsheet / sheet management + "+create": "+workbook-create", + "+info": "+workbook-info", + "+export": "+workbook-export", + "+create-sheet": "+sheet-create", + "+copy-sheet": "+sheet-copy", + "+delete-sheet": "+sheet-delete", + "+update-sheet": "+sheet-rename / +sheet-move / …", + // cell data + "+read": "+cells-get", + "+write": "+cells-set", + "+append": "+cells-set", + "+find": "+cells-search", + "+replace": "+cells-replace", + // cell style / merge / image + "+set-style": "+cells-set-style", + "+batch-set-style": "+cells-batch-set-style", + "+merge-cells": "+cells-merge", + "+unmerge-cells": "+cells-unmerge", + "+write-image": "+cells-set-image", + // row / column dimensions + "+add-dimension": "+dim-insert", + "+insert-dimension": "+dim-insert", + "+update-dimension": "+rows-resize / +dim-hide / …", + "+move-dimension": "+dim-move", + "+delete-dimension": "+dim-delete", + // filter views (conditions folded into the view flags) + "+create-filter-view": "+filter-view-create", + "+update-filter-view": "+filter-view-update", + "+list-filter-views": "+filter-view-list", + "+get-filter-view": "+filter-view-list", + "+delete-filter-view": "+filter-view-delete", + "+create-filter-view-condition": "+filter-view-update", + "+update-filter-view-condition": "+filter-view-update", + "+list-filter-view-conditions": "+filter-view-list", + "+get-filter-view-condition": "+filter-view-list", + "+delete-filter-view-condition": "+filter-view-update", + // dropdowns + "+set-dropdown": "+dropdown-set", + "+update-dropdown": "+dropdown-update", + "+get-dropdown": "+dropdown-get", + "+delete-dropdown": "+dropdown-delete", + // float images (media-upload folded into create) + "+media-upload": "+float-image-create", + "+create-float-image": "+float-image-create", + "+update-float-image": "+float-image-update", + "+get-float-image": "+float-image-list", + "+list-float-images": "+float-image-list", + "+delete-float-image": "+float-image-delete", +} + +func applySheetsCompatGroups(svc *cobra.Command) { + svc.AddGroup( + &cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"}, + &cobra.Group{ + ID: sheetsDeprecatedGroupID, + Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update", + }, + ) + + deprecated := make(map[string]struct{}) + for _, s := range sheetsbackward.Shortcuts() { + deprecated[s.Command] = struct{}{} + } + + for _, c := range svc.Commands() { + name := c.Name() + if _, ok := deprecated[name]; ok { + c.GroupID = sheetsDeprecatedGroupID + if repl := sheetsAliasReplacement[name]; repl != "" { + c.Short = c.Short + " (→ " + repl + ")" + } + continue + } + // Only the refactored shortcuts (all "+"-prefixed) belong in the current + // group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the + // auto-added help/completion ungrouped so cobra files them under + // "Additional Commands". + if len(name) > 0 && name[0] == '+' { + c.GroupID = sheetsCurrentGroupID + } + } +} + +// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets +// alias so that invoking it records a process-level deprecation notice, which +// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users +// the --help grouping cannot: those whose pre-refactor skill calls +read / +// +write directly and never reads --help. Replacement targets come from +// sheetsAliasReplacement — the same single source of truth that drives the +// "(→ +new)" help pointers. +func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut { + for i := range list { + notice := &deprecation.Notice{ + Command: list[i].Command, + Replacement: sheetsAliasReplacement[list[i].Command], + Skill: "lark-sheets", + } + // Record the notice as soon as the command's own logic runs, so it is + // surfaced even when Validate rejects the call — an out-of-date skill + // can pass pre-refactor argument shapes (e.g. a range without the new + // sheet-id prefix) and fail validation before Execute — and when + // --dry-run short-circuits before Execute. Both hooks store the same + // pointer, so setting it twice is harmless. + if origValidate := list[i].Validate; origValidate != nil { + list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error { + deprecation.SetPending(notice) + return origValidate(ctx, runtime) + } + } + if origExecute := list[i].Execute; origExecute != nil { + list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + deprecation.SetPending(notice) + return origExecute(ctx, runtime) + } + } + // The Validate/Execute wrappers above miss one path: a cobra-level + // required flag (MarkFlagRequired) that is absent fails at + // ValidateRequiredFlags, before RunE — so neither hook runs and the + // notice would be lost on exactly the "stale skill calls the old command + // and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE, + // ahead of ValidateRequiredFlags, so the notice still surfaces there. + list[i].OnInvoke = func() { deprecation.SetPending(notice) } + } + return list +} diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 82b067c62..4f7cb52d0 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -5,6 +5,7 @@ package shortcuts import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -16,7 +17,9 @@ import ( "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -471,3 +474,152 @@ func TestGenerateShortcutsJSON(t *testing.T) { } t.Logf("wrote %d bytes to %s", len(data), output) } + +// applySheetsCompatGroups must split the sheets service into a current group +// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases), +// append a "(→ +new)" migration pointer to each alias, and leave non-"+" +// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them +// under "Additional Commands". +func TestApplySheetsCompatGroups(t *testing.T) { + svc := &cobra.Command{Use: "sheets"} + newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"} + aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"} + metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"} + svc.AddCommand(newCmd, aliasCmd, metaCmd) + + applySheetsCompatGroups(svc) + + if !svc.ContainsGroup(sheetsCurrentGroupID) { + t.Errorf("current group %q not registered", sheetsCurrentGroupID) + } + if !svc.ContainsGroup(sheetsDeprecatedGroupID) { + t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID) + } + if newCmd.GroupID != sheetsCurrentGroupID { + t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID) + } + if aliasCmd.GroupID != sheetsDeprecatedGroupID { + t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID) + } + if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") { + t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short) + } + if metaCmd.GroupID != "" { + t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID) + } +} + +// End-to-end: the rendered `sheets --help` must surface the deprecated-group +// heading (telling users to update their skill) plus the per-alias migration +// pointers, while keeping the refactored shortcuts under Available Commands. +func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newRegisterTestFactory(t)) + + sheetsCmd, _, err := program.Find([]string{"sheets"}) + if err != nil { + t.Fatalf("find sheets command: %v", err) + } + + var out bytes.Buffer + sheetsCmd.SetOut(&out) + if err := sheetsCmd.Help(); err != nil { + t.Fatalf("sheets help failed: %v", err) + } + got := out.String() + + for _, want := range []string{ + "Available Commands:", + "Deprecated pre-refactor commands", + "update your lark-sheets skill", + "+read", + "(→ +cells-get)", + "+write", + "(→ +cells-set)", + } { + if !strings.Contains(got, want) { + t.Fatalf("sheets help missing %q:\n%s", want, got) + } + } +} + +// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that +// invoking it records a process-level deprecation notice (reusing +// sheetsAliasReplacement for the migration target) while still calling the +// original Execute. cmd/root.go reads that notice into the JSON "_notice". +func TestWrapSheetsBackwardDeprecation(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + called := false + in := []common.Shortcut{{ + Service: "sheets", + Command: "+read", + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + called = true + return nil + }, + }} + + out := wrapSheetsBackwardDeprecation(in) + if len(out) != 1 { + t.Fatalf("wrapped list len = %d, want 1", len(out)) + } + if deprecation.GetPending() != nil { + t.Fatal("notice set before wrapped Execute ran") + } + + if err := out[0].Execute(context.Background(), nil); err != nil { + t.Fatalf("wrapped Execute returned error: %v", err) + } + if !called { + t.Fatal("original Execute was not invoked by the wrapper") + } + + dep := deprecation.GetPending() + if dep == nil { + t.Fatal("expected a pending deprecation notice after Execute") + } + if dep.Command != "+read" { + t.Errorf("notice Command = %q, want +read", dep.Command) + } + if dep.Replacement != "+cells-get" { + t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement) + } + if dep.Skill != "lark-sheets" { + t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill) + } +} + +// The wrapper must also decorate Validate, so an out-of-date skill whose +// pre-refactor argument shape fails validation (before Execute) still gets the +// deprecation notice in its error envelope. +func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + validated := false + in := []common.Shortcut{{ + Service: "sheets", + Command: "+write", + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + validated = true + return nil + }, + }} + + out := wrapSheetsBackwardDeprecation(in) + if out[0].Validate == nil { + t.Fatal("Validate hook was dropped by the wrapper") + } + if err := out[0].Validate(context.Background(), nil); err != nil { + t.Fatalf("wrapped Validate returned error: %v", err) + } + if !validated { + t.Fatal("original Validate was not invoked") + } + dep := deprecation.GetPending() + if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" { + t.Fatalf("Validate hook did not record expected notice: %#v", dep) + } +} diff --git a/shortcuts/sheets/backward/helpers.go b/shortcuts/sheets/backward/helpers.go new file mode 100644 index 000000000..9c8f3284c --- /dev/null +++ b/shortcuts/sheets/backward/helpers.go @@ -0,0 +1,239 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ( + singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`) + cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`) + cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`) + colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`) + rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`) + cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`) +) + +var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!") + +// getFirstSheetID queries the spreadsheet and returns the first sheet's ID. +func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) { + data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil) + if err != nil { + return "", err + } + sheets, _ := data["sheets"].([]interface{}) + if len(sheets) > 0 { + sheet, _ := sheets[0].(map[string]interface{}) + if id, ok := sheet["sheet_id"].(string); ok && id != "" { + return id, nil + } + } + return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet") +} + +// extractSpreadsheetToken extracts spreadsheet token from URL. +func extractSpreadsheetToken(input string) string { + input = strings.TrimSpace(input) + prefixes := []string{"/sheets/", "/spreadsheets/"} + for _, prefix := range prefixes { + if idx := strings.Index(input, prefix); idx >= 0 { + token := input[idx+len(prefix):] + if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 { + token = token[:idx2] + } + return token + } + } + return input +} + +func normalizeSheetRange(sheetID, input string) string { + input = normalizeSheetRangeSeparators(input) + if input == "" || strings.Contains(input, "!") || sheetID == "" { + return input + } + if looksLikeRelativeRange(input) { + return sheetID + "!" + input + } + return input +} + +func normalizePointRange(sheetID, input string) string { + input = normalizeSheetRange(sheetID, input) + if input == "" { + return input + } + rangeSheetID, subRange, ok := splitSheetRange(input) + if !ok || !singleCellRangePattern.MatchString(subRange) { + return input + } + return rangeSheetID + "!" + subRange + ":" + subRange +} + +func normalizeWriteRange(sheetID, input string, values interface{}) string { + rows, cols := matrixDimensions(values) + input = normalizeSheetRangeSeparators(input) + if input == "" { + return buildRectRange(sheetID, "A1", rows, cols) + } + + input = normalizeSheetRange(sheetID, input) + rangeSheetID, subRange, ok := splitSheetRange(input) + if !ok { + return buildRectRange(input, "A1", rows, cols) + } + if singleCellRangePattern.MatchString(subRange) { + return buildRectRange(rangeSheetID, subRange, rows, cols) + } + return input +} + +func validateSheetRangeInput(sheetID, input string) error { + input = normalizeSheetRangeSeparators(input) + if input == "" || strings.Contains(input, "!") || sheetID != "" { + return nil + } + if looksLikeRelativeRange(input) { + return common.FlagErrorf("--range %q requires --sheet-id or a ! prefix", input) + } + return nil +} + +// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are +// invalid for single-cell operations like write-image. Empty and single-cell +// values pass through. +func validateSingleCellRange(input string) error { + input = normalizeSheetRangeSeparators(input) + if input == "" { + return nil + } + // Extract the sub-range after the sheet ID prefix, if present. + subRange := input + if _, sr, ok := splitSheetRange(input); ok { + subRange = sr + } + if cellSpanRangePattern.MatchString(subRange) { + parts := strings.SplitN(subRange, ":", 2) + if strings.EqualFold(parts[0], parts[1]) { + return nil + } + return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input) + } + return nil +} + +func looksLikeRelativeRange(input string) bool { + input = normalizeSheetRangeSeparators(input) + if input == "" { + return false + } + return singleCellRangePattern.MatchString(input) || + cellSpanRangePattern.MatchString(input) || + cellToColRangePattern.MatchString(input) || + colSpanRangePattern.MatchString(input) || + rowSpanRangePattern.MatchString(input) +} + +func splitSheetRange(input string) (sheetID, subRange string, ok bool) { + parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +func normalizeSheetRangeSeparators(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return input + } + return sheetRangeSeparatorReplacer.Replace(input) +} + +func buildRectRange(sheetID, anchor string, rows, cols int) string { + if sheetID == "" { + return "" + } + if rows < 1 { + rows = 1 + } + if cols < 1 { + cols = 1 + } + endCell, err := offsetCell(anchor, rows-1, cols-1) + if err != nil { + return sheetID + } + return sheetID + "!" + anchor + ":" + endCell +} + +func matrixDimensions(values interface{}) (rows, cols int) { + rowList, ok := values.([]interface{}) + if !ok || len(rowList) == 0 { + return 1, 1 + } + rows = len(rowList) + for _, row := range rowList { + if cells, ok := row.([]interface{}); ok && len(cells) > cols { + cols = len(cells) + } + } + if cols == 0 { + cols = 1 + } + return rows, cols +} + +func offsetCell(cell string, rowOffset, colOffset int) (string, error) { + matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell)) + if len(matches) != 3 { + return "", fmt.Errorf("invalid cell reference: %s", cell) + } + colIndex := columnNameToIndex(matches[1]) + if colIndex < 1 { + return "", fmt.Errorf("invalid column: %s", matches[1]) + } + rowIndex, err := strconv.Atoi(matches[2]) + if err != nil { + return "", err + } + return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil +} + +func columnNameToIndex(name string) int { + name = strings.ToUpper(strings.TrimSpace(name)) + if name == "" { + return 0 + } + index := 0 + for _, r := range name { + if r < 'A' || r > 'Z' { + return 0 + } + index = index*26 + int(r-'A'+1) + } + return index +} + +func columnIndexToName(index int) string { + if index < 1 { + return "" + } + var out []byte + for index > 0 { + index-- + out = append([]byte{byte('A' + index%26)}, out...) + index /= 26 + } + return string(out) +} diff --git a/shortcuts/sheets/lark_sheets_cell_data.go b/shortcuts/sheets/backward/lark_sheets_cell_data.go similarity index 87% rename from shortcuts/sheets/lark_sheets_cell_data.go rename to shortcuts/sheets/backward/lark_sheets_cell_data.go index 8c654716f..59d6fa74e 100644 --- a/shortcuts/sheets/lark_sheets_cell_data.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_data.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" @@ -54,10 +54,14 @@ var SheetRead = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := validateSheetManageToken(runtime) readRange := runtime.Str("range") - if readRange == "" && runtime.Str("sheet-id") != "" { + if readRange == "" { + // Sheet-only selector: pass the bare sheet id through verbatim. + // Routing it via the range normalizer mangles ids that look + // A1-ish (e.g. "shtABC123" -> "shtABC123!shtABC123:shtABC123"). readRange = runtime.Str("sheet-id") + } else { + readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) } - readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) return common.NewDryRunAPI(). GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range"). Set("token", token).Set("range", readRange) @@ -66,18 +70,19 @@ var SheetRead = common.Shortcut{ token, _ := validateSheetManageToken(runtime) readRange := runtime.Str("range") - if readRange == "" && runtime.Str("sheet-id") != "" { - readRange = runtime.Str("sheet-id") - } - if readRange == "" { - var err error - readRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err + // Sheet-only selector: keep the resolved sheet id verbatim (see DryRun). + readRange = runtime.Str("sheet-id") + if readRange == "" { + var err error + readRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } } + } else { + readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) } - readRange = normalizePointRange(runtime.Str("sheet-id"), readRange) params := map[string]interface{}{} renderOption := runtime.Str("value-render-option") @@ -124,11 +129,14 @@ var SheetWrite = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := validateSheetManageToken(runtime) writeRange := runtime.Str("range") - if writeRange == "" && runtime.Str("sheet-id") != "" { - writeRange = runtime.Str("sheet-id") - } values, _ := parseValues2DJSON(runtime.Str("values")) - writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) + if writeRange == "" { + // Sheet-only selector: build the write rect from the selector's + // A1 instead of treating the bare sheet id as a cell anchor. + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), "", values) + } else { + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) + } return common.NewDryRunAPI(). PUT("/open-apis/sheets/v2/spreadsheets/:token/values"). Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}). @@ -143,18 +151,21 @@ var SheetWrite = common.Shortcut{ } writeRange := runtime.Str("range") - if writeRange == "" && runtime.Str("sheet-id") != "" { - writeRange = runtime.Str("sheet-id") - } - if writeRange == "" { - var err error - writeRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err + // Sheet-only selector: build the write rect from the selector's + // A1 (see DryRun). Resolve the first sheet when none was given. + sel := runtime.Str("sheet-id") + if sel == "" { + var err error + sel, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } } + writeRange = normalizeWriteRange(sel, "", values) + } else { + writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) } - writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values) data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "valueRange": map[string]interface{}{ @@ -200,11 +211,14 @@ var SheetAppend = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := validateSheetManageToken(runtime) appendRange := runtime.Str("range") - if appendRange == "" && runtime.Str("sheet-id") != "" { + if appendRange == "" { + // Sheet-only selector: pass the bare sheet id through verbatim + // (see SheetRead.DryRun for the normalizer-mangling rationale). appendRange = runtime.Str("sheet-id") + } else { + appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) } values, _ := parseValues2DJSON(runtime.Str("values")) - appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) return common.NewDryRunAPI(). POST("/open-apis/sheets/v2/spreadsheets/:token/values_append"). Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}). @@ -219,18 +233,19 @@ var SheetAppend = common.Shortcut{ } appendRange := runtime.Str("range") - if appendRange == "" && runtime.Str("sheet-id") != "" { - appendRange = runtime.Str("sheet-id") - } - if appendRange == "" { - var err error - appendRange, err = getFirstSheetID(runtime, token) - if err != nil { - return err + // Sheet-only selector: keep the resolved sheet id verbatim (see DryRun). + appendRange = runtime.Str("sheet-id") + if appendRange == "" { + var err error + appendRange, err = getFirstSheetID(runtime, token) + if err != nil { + return err + } } + } else { + appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) } - appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange) data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{ "valueRange": map[string]interface{}{ diff --git a/shortcuts/sheets/lark_sheets_cell_images.go b/shortcuts/sheets/backward/lark_sheets_cell_images.go similarity index 99% rename from shortcuts/sheets/lark_sheets_cell_images.go rename to shortcuts/sheets/backward/lark_sheets_cell_images.go index cd2ba9c66..d2c0af692 100644 --- a/shortcuts/sheets/lark_sheets_cell_images.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_images.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_cell_style_and_merge.go b/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go similarity index 99% rename from shortcuts/sheets/lark_sheets_cell_style_and_merge.go rename to shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go index 3553fd945..520f91e36 100644 --- a/shortcuts/sheets/lark_sheets_cell_style_and_merge.go +++ b/shortcuts/sheets/backward/lark_sheets_cell_style_and_merge.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_dropdown.go b/shortcuts/sheets/backward/lark_sheets_dropdown.go similarity index 99% rename from shortcuts/sheets/lark_sheets_dropdown.go rename to shortcuts/sheets/backward/lark_sheets_dropdown.go index fe092bbac..e5645af65 100644 --- a/shortcuts/sheets/lark_sheets_dropdown.go +++ b/shortcuts/sheets/backward/lark_sheets_dropdown.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_filter_views.go b/shortcuts/sheets/backward/lark_sheets_filter_views.go similarity index 99% rename from shortcuts/sheets/lark_sheets_filter_views.go rename to shortcuts/sheets/backward/lark_sheets_filter_views.go index ec385a9b4..b76a473f3 100644 --- a/shortcuts/sheets/lark_sheets_filter_views.go +++ b/shortcuts/sheets/backward/lark_sheets_filter_views.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go similarity index 99% rename from shortcuts/sheets/lark_sheets_float_images.go rename to shortcuts/sheets/backward/lark_sheets_float_images.go index cb70d6ca0..a0b7bf490 100644 --- a/shortcuts/sheets/lark_sheets_float_images.go +++ b/shortcuts/sheets/backward/lark_sheets_float_images.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_row_column_management.go b/shortcuts/sheets/backward/lark_sheets_row_column_management.go similarity index 99% rename from shortcuts/sheets/lark_sheets_row_column_management.go rename to shortcuts/sheets/backward/lark_sheets_row_column_management.go index 5d5f9dec5..581c8eb0e 100644 --- a/shortcuts/sheets/lark_sheets_row_column_management.go +++ b/shortcuts/sheets/backward/lark_sheets_row_column_management.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_cell_ops_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_cell_ops_test.go index 708fc9f1a..c0dd5b070 100644 --- a/shortcuts/sheets/lark_sheets_sheet_cell_ops_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_cell_ops_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_sheet_create_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_create_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_create_test.go index 665de88c0..cfd4781c4 100644 --- a/shortcuts/sheets/lark_sheets_sheet_create_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_create_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "bytes" diff --git a/shortcuts/sheets/lark_sheets_sheet_dimension_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_dimension_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_dimension_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_dimension_test.go index ee165412a..51b13ff5b 100644 --- a/shortcuts/sheets/lark_sheets_sheet_dimension_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_dimension_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_sheet_dropdown_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_dropdown_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_dropdown_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_dropdown_test.go index 135d91ce3..e07dbd2f1 100644 --- a/shortcuts/sheets/lark_sheets_sheet_dropdown_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_dropdown_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "bytes" diff --git a/shortcuts/sheets/lark_sheets_sheet_export_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_export_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_export_test.go index 83bef63de..afe456b5d 100644 --- a/shortcuts/sheets/lark_sheets_sheet_export_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_export_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_sheet_filter_view_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_filter_view_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_filter_view_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_filter_view_test.go index a28aec242..66e49d4a5 100644 --- a/shortcuts/sheets/lark_sheets_sheet_filter_view_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_filter_view_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_sheet_float_image_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_float_image_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_float_image_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_float_image_test.go index e8658d199..e9640098a 100644 --- a/shortcuts/sheets/lark_sheets_sheet_float_image_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_float_image_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_sheet_manage_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_manage_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go index 97156ca5d..1a8115b7a 100644 --- a/shortcuts/sheets/lark_sheets_sheet_manage_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_manage_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/lark_sheets_sheet_management.go b/shortcuts/sheets/backward/lark_sheets_sheet_management.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_management.go rename to shortcuts/sheets/backward/lark_sheets_sheet_management.go index bfbd56494..0484a6cd4 100644 --- a/shortcuts/sheets/lark_sheets_sheet_management.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_management.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" @@ -355,6 +355,7 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error { Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail), }, Err: err, + Raw: exitErr.Raw, } } diff --git a/shortcuts/sheets/lark_sheets_sheet_media_upload_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_media_upload_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go index 181ebcdf7..03b104160 100644 --- a/shortcuts/sheets/lark_sheets_sheet_media_upload_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "bytes" diff --git a/shortcuts/sheets/lark_sheets_sheet_ranges_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go similarity index 81% rename from shortcuts/sheets/lark_sheets_sheet_ranges_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go index a4b2a4eb4..e9961eb97 100644 --- a/shortcuts/sheets/lark_sheets_sheet_ranges_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_ranges_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" @@ -126,6 +126,60 @@ func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) { } } +// A bare sheet selector (no --range) must pass through verbatim. Sheet ids +// that look A1-ish (letters+digits) would otherwise be mangled by the range +// normalizer into "!:". + +func TestSheetReadDryRunSheetOnlyVerbatim(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "", + "sheet-id": "shtABC123", + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"shtABC123"`) { + t.Fatalf("SheetRead.DryRun() = %s, want bare sheet id verbatim", got) + } +} + +func TestSheetWriteDryRunSheetOnlyBuildsRect(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "", + "sheet-id": "shtABC123", + "values": `[["x"]]`, + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime)) + // Built from the sheet's A1 (A1:A1 for a 1x1 write), NOT the mangled + // "shtABC123!shtABC123:shtABC123" that piping a bare id through the + // range normalizer produced. + if !strings.Contains(got, `"range":"shtABC123!A1:A1"`) { + t.Fatalf("SheetWrite.DryRun() = %s, want rect built from sheet A1", got) + } +} + +func TestSheetAppendDryRunSheetOnlyVerbatim(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": "", + "sheet-id": "shtABC123", + "values": `[["foo"]]`, + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"shtABC123"`) { + t.Fatalf("SheetAppend.DryRun() = %s, want bare sheet id verbatim", got) + } +} + func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) { t.Parallel() diff --git a/shortcuts/sheets/lark_sheets_sheet_write_image_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_write_image_test.go similarity index 99% rename from shortcuts/sheets/lark_sheets_sheet_write_image_test.go rename to shortcuts/sheets/backward/lark_sheets_sheet_write_image_test.go index 05ee69912..02b6701a4 100644 --- a/shortcuts/sheets/lark_sheets_sheet_write_image_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_write_image_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "bytes" diff --git a/shortcuts/sheets/lark_sheets_spreadsheet_management.go b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go similarity index 99% rename from shortcuts/sheets/lark_sheets_spreadsheet_management.go rename to shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go index d60b5df27..5aa1fdaec 100644 --- a/shortcuts/sheets/lark_sheets_spreadsheet_management.go +++ b/shortcuts/sheets/backward/lark_sheets_spreadsheet_management.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package sheets +package backward import ( "context" diff --git a/shortcuts/sheets/backward/shortcuts.go b/shortcuts/sheets/backward/shortcuts.go new file mode 100644 index 000000000..8fd8bb2ac --- /dev/null +++ b/shortcuts/sheets/backward/shortcuts.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package backward + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all sheets shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + // Spreadsheet management + SheetCreate, + SheetInfo, + SheetExport, + + // Sheet management + SheetCreateSheet, + SheetCopySheet, + SheetDeleteSheet, + SheetUpdateSheet, + + // Cell data + SheetRead, + SheetWrite, + SheetAppend, + SheetFind, + SheetReplace, + + // Cell style and merge + SheetSetStyle, + SheetBatchSetStyle, + SheetMergeCells, + SheetUnmergeCells, + + // Cell images + SheetWriteImage, + + // Row/column management + SheetAddDimension, + SheetInsertDimension, + SheetUpdateDimension, + SheetMoveDimension, + SheetDeleteDimension, + + // Filter views + SheetCreateFilterView, + SheetUpdateFilterView, + SheetListFilterViews, + SheetGetFilterView, + SheetDeleteFilterView, + SheetCreateFilterViewCondition, + SheetUpdateFilterViewCondition, + SheetListFilterViewConditions, + SheetGetFilterViewCondition, + SheetDeleteFilterViewCondition, + + // Dropdown + SheetSetDropdown, + SheetUpdateDropdown, + SheetGetDropdown, + SheetDeleteDropdown, + + // Float images + SheetMediaUpload, + SheetCreateFloatImage, + SheetUpdateFloatImage, + SheetGetFloatImage, + SheetListFloatImages, + SheetDeleteFloatImage, + } +} diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go new file mode 100644 index 000000000..b5d552a79 --- /dev/null +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -0,0 +1,907 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestBatchOp_BodyMatchesStandalone is the core contract: for every batchable +// shortcut, the MCP body produced inside +batch-update must be byte-for-byte +// identical to the body the same shortcut produces when invoked standalone +// (both observed via --dry-run, comparing tool_name + decoded input). This is +// what guarantees "a sub-op behaves exactly like the standalone command", and +// it is the regression guard for the whole flag→body translator reuse. +// +// Each case provides the standalone CLI args and the equivalent sub-op input +// object (same CLI flag names, minus the spreadsheet locator which the batch +// supplies at the top level). +func TestBatchOp_BodyMatchesStandalone(t *testing.T) { + t.Parallel() + + cases := []struct { + shortcut string + sc common.Shortcut + // standalone args (excluding --url, which every case shares) + args []string + // sub-op input object as JSON (CLI flag names; no excel_id/url) + subInput string + }{ + { + shortcut: "+cells-set", + sc: CellsSet, + args: []string{"--sheet-id", "sh1", "--range", "A1:B1", "--cells", `[[{"value":"x"},{"value":"y"}]]`}, + subInput: `{"sheet-id":"sh1","range":"A1:B1","cells":[[{"value":"x"},{"value":"y"}]]}`, + }, + { + shortcut: "+cells-clear", + sc: CellsClear, + args: []string{"--sheet-id", "sh1", "--range", "A1:C3", "--scope", "formats"}, + subInput: `{"sheet-id":"sh1","range":"A1:C3","scope":"formats"}`, + }, + { + shortcut: "+cells-replace", + sc: CellsReplace, + args: []string{"--sheet-id", "sh1", "--find", "foo", "--replacement", "bar", "--match-case"}, + subInput: `{"sheet-id":"sh1","find":"foo","replacement":"bar","match-case":true}`, + }, + { + shortcut: "+csv-put", + sc: CsvPut, + args: []string{"--sheet-id", "sh1", "--csv", "a,b\n1,2", "--start-cell", "B2"}, + subInput: `{"sheet-id":"sh1","csv":"a,b\n1,2","start-cell":"B2"}`, + }, + { + shortcut: "+cells-merge", + sc: CellsMerge, + args: []string{"--sheet-id", "sh1", "--range", "A1:C1", "--merge-type", "rows"}, + subInput: `{"sheet-id":"sh1","range":"A1:C1","merge-type":"rows"}`, + }, + { + shortcut: "+cells-unmerge", + sc: CellsUnmerge, + args: []string{"--sheet-id", "sh1", "--range", "A1:C1"}, + subInput: `{"sheet-id":"sh1","range":"A1:C1"}`, + }, + { + shortcut: "+dim-insert", + sc: DimInsert, + args: []string{"--sheet-id", "sh1", "--position", "11", "--count", "2", "--inherit-style", "before"}, + subInput: `{"sheet-id":"sh1","position":"11","count":2,"inherit-style":"before"}`, + }, + { + shortcut: "+dim-delete", + sc: DimDelete, + args: []string{"--sheet-id", "sh1", "--range", "C:D"}, + subInput: `{"sheet-id":"sh1","range":"C:D"}`, + }, + { + shortcut: "+dim-hide", + sc: DimHide, + args: []string{"--sheet-id", "sh1", "--range", "2:3"}, + subInput: `{"sheet-id":"sh1","range":"2:3"}`, + }, + { + shortcut: "+dim-freeze", + sc: DimFreeze, + args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"}, + subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`, + }, + { + shortcut: "+dim-group", + sc: DimGroup, + args: []string{"--sheet-id", "sh1", "--range", "2:5", "--group-state", "fold"}, + subInput: `{"sheet-id":"sh1","range":"2:5","group-state":"fold"}`, + }, + { + 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}`, + }, + { + shortcut: "+cols-resize", + sc: ColsResize, + args: []string{"--sheet-id", "sh1", "--range", "B:D", "--type", "standard"}, + subInput: `{"sheet-id":"sh1","range":"B:D","type":"standard"}`, + }, + { + shortcut: "+range-move", + sc: RangeMove, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:C5", "--target-range", "D1"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:C5","target-range":"D1"}`, + }, + { + shortcut: "+range-copy", + sc: RangeCopy, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:B2", "--target-range", "A10", "--paste-type", "values"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"values"}`, + }, + { + shortcut: "+range-fill", + sc: RangeFill, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:A2", "--target-range", "A1:A10", "--series-type", "linear"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:A2","target-range":"A1:A10","series-type":"linear"}`, + }, + { + shortcut: "+range-sort", + sc: RangeSort, + args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"column":"B","ascending":true}]`, "--has-header"}, + subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"column":"B","ascending":true}],"has-header":true}`, + }, + { + shortcut: "+sheet-create", + sc: SheetCreate, + args: []string{"--title", "New", "--index", "2"}, + subInput: `{"title":"New","index":2}`, + }, + { + shortcut: "+sheet-delete", + sc: SheetDelete, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-rename", + sc: SheetRename, + args: []string{"--sheet-id", "sh1", "--title", "Renamed"}, + subInput: `{"sheet-id":"sh1","title":"Renamed"}`, + }, + { + shortcut: "+sheet-copy", + sc: SheetCopy, + args: []string{"--sheet-id", "sh1", "--title", "Copy"}, + subInput: `{"sheet-id":"sh1","title":"Copy"}`, + }, + { + shortcut: "+sheet-hide", + sc: SheetHide, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-unhide", + sc: SheetUnhide, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-set-tab-color", + sc: SheetSetTabColor, + args: []string{"--sheet-id", "sh1", "--color", "#FF0000"}, + subInput: `{"sheet-id":"sh1","color":"#FF0000"}`, + }, + { + shortcut: "+dropdown-set", + sc: DropdownSet, + args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"}, + subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`, + }, + { + // --highlight=false explicitly opts out of the server's new + // enable_highlight=true default. Covers the tri-state Changed() + // branch in buildDropdownValidation: standalone reads the cobra + // "Changed" bit; sub-op reads the key's presence in the map. + shortcut: "+dropdown-set", + sc: DropdownSet, + args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--highlight=false"}, + subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"highlight":false}`, + }, + { + shortcut: "+chart-create", + sc: ChartCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}, + subInput: `{"sheet-id":"sh1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`, + }, + { + shortcut: "+chart-update", + sc: ChartUpdate, + args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}, + subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`, + }, + { + shortcut: "+chart-delete", + sc: ChartDelete, + args: []string{"--sheet-id", "sh1", "--chart-id", "c1"}, + subInput: `{"sheet-id":"sh1","chart-id":"c1"}`, + }, + { + shortcut: "+pivot-create", + sc: PivotCreate, + // +pivot-create renamed --sheet-id / --sheet-name → --target-sheet-id / + // --target-sheet-name to flag the placement-sheet semantics (the data + // source is in --source). Both standalone args and the +batch-update + // sub-op input must use the new names. + args: []string{"--target-sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"}, + subInput: `{"target-sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`, + }, + { + shortcut: "+cond-format-create", + sc: CondFormatCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicateValues", "--ranges", `["A1:A100"]`}, + subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicateValues","ranges":["A1:A100"]}`, + }, + { + shortcut: "+filter-create", + sc: FilterCreate, + args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`, + }, + { + shortcut: "+filter-update", + sc: FilterUpdate, + args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`, + }, + { + shortcut: "+filter-delete", + sc: FilterDelete, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+filter-view-create", + sc: FilterViewCreate, + args: []string{"--sheet-id", "sh1", "--range", "A1:Z100", "--view-name", "v1", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:Z100","view-name":"v1","properties":{"rules":[]}}`, + }, + { + shortcut: "+sparkline-create", + sc: SparklineCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"type":"line","data_range":"A2:F2","target_range":"G2"}`}, + subInput: `{"sheet-id":"sh1","properties":{"type":"line","data_range":"A2:F2","target_range":"G2"}}`, + }, + { + shortcut: "+sparkline-delete", + sc: SparklineDelete, + args: []string{"--sheet-id", "sh1", "--group-id", "g1"}, + subInput: `{"sheet-id":"sh1","group-id":"g1"}`, + }, + { + shortcut: "+float-image-create", + sc: FloatImageCreate, + args: []string{"--sheet-id", "sh1", "--image-name", "logo.png", "--image-token", "tok", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"}, + subInput: `{"sheet-id":"sh1","image-name":"logo.png","image-token":"tok","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + }, + { + shortcut: "+float-image-delete", + sc: FloatImageDelete, + args: []string{"--sheet-id", "sh1", "--float-image-id", "fi1"}, + subInput: `{"sheet-id":"sh1","float-image-id":"fi1"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.shortcut, func(t *testing.T) { + t.Parallel() + + mapping, ok := batchOpDispatch[tc.shortcut] + if !ok { + t.Fatalf("%s not in batchOpDispatch", tc.shortcut) + } + + // Standalone body via the shortcut's own dry-run. + standaloneBody := decodeToolInput(t, parseDryRunBody(t, tc.sc, append([]string{"--url", testURL}, tc.args...)), mapping.mcpToolName) + + // Batch body via the +batch-update translator. + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + fv := newMapFlagViewForCommand(tc.shortcut, subInput) + // Match what translateBatchOp does — read the sheet selector + // via the shortcut-specific flag names so +pivot-create + // (target-sheet-id / target-sheet-name) and the rest + // (sheet-id / sheet-name) both resolve correctly. + sidFlag, snameFlag := sheetSelectorFlagsForSubOp(tc.shortcut) + sidStr, _ := subInput[sidFlag].(string) + snameStr, _ := subInput[snameFlag].(string) + batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr) + if err != nil { + t.Fatalf("batch translate failed: %v", err) + } + + // Round-trip the batch body through JSON so number types match the + // standalone path (which is decoded from a JSON string). + batchBody = jsonRoundTrip(t, batchBody) + + if !reflect.DeepEqual(standaloneBody, batchBody) { + t.Errorf("%s: batch body != standalone body\n standalone=%#v\n batch =%#v", tc.shortcut, standaloneBody, batchBody) + } + }) + } +} + +func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{} { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]interface{} + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// TestBatchOp_ErrorEquivalence is the second half of the contract: for the +// same bad input, the standalone shortcut Validate and the +batch-update +// sub-op translator must emit the same friendly CLI error. Previously a +// sub-op that omitted --sheet-id (or another required flag) slipped through +// to the server and surfaced as "sheet undefined not found"; with the +// validation pushed down into the xxxInput builders both paths now stop the +// request before the API call. +// +// Scope: this test covers checks that cobra cannot enforce — XOR pairs +// (sheet selector, image token/uri), range relationships, enum-bound rules, +// pixel/size cross-flag coupling. cobra's own MarkFlagRequired catches the +// single-required cases on the standalone path with its own +// "required flag(s) \"X\" not set" wording; the batch path now catches the +// same situations with our friendlier "--X is required" wording — those are +// asserted by TestBatchOp_RejectsBadSubOpInput below. +func TestBatchOp_ErrorEquivalence(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + // shortcut & standalone args. --url is supplied by the runner. Args + // satisfy every cobra-required flag so cobra doesn't short-circuit + // before our shared validator runs. + shortcut common.Shortcut + args []string + // matching sub-op input; reach the same failing check. + subShortcut string + subInput string + // substring expected in both errors. We assert *contains* rather than + // equality because the batch path wraps the inner error with + // "operations[i] (): " context — the inner message must match. + wantContains string + }{ + { + name: "+cells-set missing sheet selector", + shortcut: CellsSet, + args: []string{"--range", "A1", "--cells", `[[{"value":"x"}]]`}, + subShortcut: "+cells-set", + subInput: `{"range":"A1","cells":[[{"value":"x"}]]}`, + wantContains: "specify at least one of --sheet-id or --sheet-name", + }, + { + name: "+cells-set both sheet-id and sheet-name", + shortcut: CellsSet, + args: []string{"--sheet-id", "sh1", "--sheet-name", "Sheet1", "--range", "A1", "--cells", `[[{"value":"x"}]]`}, + subShortcut: "+cells-set", + subInput: `{"sheet-id":"sh1","sheet-name":"Sheet1","range":"A1","cells":[[{"value":"x"}]]}`, + wantContains: "mutually exclusive", + }, + { + name: "+dim-insert missing sheet selector", + shortcut: DimInsert, + args: []string{"--position", "1", "--count", "1"}, + subShortcut: "+dim-insert", + subInput: `{"position":"1","count":1}`, + wantContains: "specify at least one of --sheet-id or --sheet-name", + }, + { + name: "+dim-insert count <= 0", + shortcut: DimInsert, + args: []string{"--sheet-id", "sh1", "--position", "5", "--count", "0"}, + subShortcut: "+dim-insert", + subInput: `{"sheet-id":"sh1","position":"5","count":0}`, + wantContains: "--count must be > 0", + }, + { + name: "+rows-resize --type pixel without --size", + shortcut: RowsResize, + args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"}, + subShortcut: "+rows-resize", + subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`, + wantContains: "--type pixel requires --size", + }, + { + name: "+sheet-delete missing sheet selector", + shortcut: SheetDelete, + args: []string{}, + subShortcut: "+sheet-delete", + subInput: `{}`, + wantContains: "specify at least one of --sheet-id or --sheet-name", + }, + { + name: "+float-image-create both image-token and image-uri", + shortcut: FloatImageCreate, + args: []string{"--sheet-id", "sh1", "--image-name", "x.png", "--image-token", "t", "--image-uri", "u", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"}, + subShortcut: "+float-image-create", + subInput: `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","image-uri":"u","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + wantContains: "mutually exclusive", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Standalone path: run the shortcut with --dry-run + bad args. + // Validate runs before DryRun, so we expect it to fail there. + _, _, standaloneErr := runShortcutCapturingErr( + t, tc.shortcut, + append([]string{"--url", testURL, "--dry-run"}, tc.args...), + ) + if standaloneErr == nil { + t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains) + } + if !strings.Contains(standaloneErr.Error(), tc.wantContains) { + t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains) + } + + // Batch path: translate the matching sub-op. The translator wraps + // the inner error with "operations[i] (): " — assert the + // inner message survives the wrap. + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{ + "shortcut": tc.subShortcut, + "input": subInput, + } + _, batchErr := translateBatchOp(rawOp, testToken, 0) + if batchErr == nil { + t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains) + } + if !strings.Contains(batchErr.Error(), tc.wantContains) { + t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains) + } + // And the wrap context must include the sub-op index + shortcut + // name so error reports stay actionable in multi-op batches. + wrapHint := "operations[0] (" + tc.subShortcut + "):" + if !strings.Contains(batchErr.Error(), wrapHint) { + t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint) + } + }) + } +} + +// 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 +// whose JSON type contradicts its flag-defs type must be rejected up front +// rather than landing as 0 / false in the wrong place. +func TestBatchOp_RejectsWrongScalarType(t *testing.T) { + t.Parallel() + cases := []struct { + name string + subShortcut string + subInput string + wantContains string + }{ + { + name: "int flag given a string", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":2,"index":"abc"}`, + wantContains: "--index must be a number", + }, + { + name: "int flag given a boolean", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":true,"index":0}`, + wantContains: "--source-index must be a number", + }, + { + // Standalone cobra rejects 1.5 for an int flag at parse time; + // mapFlagView.Int would silently truncate it to 1, so the batch + // path must reject it too instead of executing on a floored index. + name: "int flag given a non-integer number", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":2,"index":1.5}`, + wantContains: "--index must be an integer", + }, + { + name: "bool flag given a string", + subShortcut: "+cells-set", + subInput: `{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]],"allow-overwrite":"true"}`, + wantContains: "--allow-overwrite must be a boolean", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput} + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains) + } + if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains) + } + }) + } +} + +// TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone +// required-flag enforcement lives OUTSIDE the shared *Input builder — so it is +// invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor: +// - +csv-put: standalone requires one-of(start-cell, range) via cobra's +// MarkFlagsOneRequired (PostMount); a batch sub-op never runs cobra. +// - +sheet-move: standalone requires --index (>=0) and source-index>=0 in +// SheetMove.Validate; the batch path uses a dedicated builder. +// +// Without an explicit guard, mapFlagView's flag-default fallback silently wins +// (start-cell→"A1", index→0), so the batch sub-op diverges from the standalone +// contract instead of failing. +func TestBatchOp_GuardsBeyondCobra(t *testing.T) { + t.Parallel() + cases := []struct { + name string + subShortcut string + subInput string + wantContains string + }{ + { + name: "+csv-put without start-cell or range", + subShortcut: "+csv-put", + subInput: `{"sheet-id":"sh1","csv":"a,b"}`, + wantContains: "--start-cell or --range is required", + }, + { + name: "+sheet-move without index", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":2}`, + wantContains: "requires index", + }, + { + name: "+sheet-move negative index", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":2,"index":-1}`, + wantContains: "--index must be >= 0", + }, + { + name: "+sheet-move negative source-index", + subShortcut: "+sheet-move", + subInput: `{"sheet-id":"sh1","source-index":-1,"index":0}`, + wantContains: "--source-index must be >= 0", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput} + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains) + } + if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains) + } + }) + } +} + +// TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for +// inputs that cobra's MarkFlagRequired catches on the standalone path, +// the +batch-update sub-op (which has no cobra layer) must still reject +// CLI-side with its own friendly error before issuing any API call. This +// closes the original bug — a sub-op missing --sheet-id used to slip +// through and surface as "sheet undefined not found" only after a +// network round-trip. +func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + subShortcut string + subInput string + wantContains string + }{ + { + "+cells-set missing --range", + "+cells-set", + `{"sheet-id":"sh1","cells":[[{"value":"x"}]]}`, + "--range is required", + }, + { + "+dim-insert missing --position", + "+dim-insert", + `{"sheet-id":"sh1","count":1}`, + "--position is required", + }, + { + "+rows-resize missing --type", + "+rows-resize", + `{"sheet-id":"sh1","range":"1:1"}`, + "--type is required", + }, + { + "+range-copy missing --target-range", + "+range-copy", + `{"sheet-id":"sh1","source-range":"A1:B2"}`, + "--target-range is required", + }, + { + "+sheet-rename missing --title", + "+sheet-rename", + `{"sheet-id":"sh1"}`, + "--title is required", + }, + { + "+chart-update missing --chart-id", + "+chart-update", + `{"sheet-id":"sh1","properties":{"title":"T"}}`, + "--chart-id is required", + }, + { + "+filter-create missing --range", + "+filter-create", + `{"sheet-id":"sh1"}`, + "--range is required", + }, + { + "+float-image-update missing --float-image-id", + "+float-image-update", + `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + "--float-image-id is required", + }, + // +float-image-update's core (image_name / position / size) is mandatory + // on update too — the tool rejects without them and +float-image-list + // can't backfill image_name. cobra gates these on the standalone path; + // the batch sub-op must reject them here. The image source stays optional + // (omitting it keeps the current image), so these inputs omit it. + { + "+float-image-update missing --image-name", + "+float-image-update", + `{"sheet-id":"sh1","float-image-id":"fi1","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + "--image-name is required", + }, + { + "+float-image-update missing position", + "+float-image-update", + `{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","size-width":100,"size-height":50}`, + "--position-row and --position-col are required", + }, + { + "+float-image-update missing size", + "+float-image-update", + `{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","position-row":0,"position-col":"A"}`, + "--size-width and --size-height are required", + }, + // +filter-{update,delete} need sheet-id (not sheet-name) because + // server contract: filter_id === sheet_id, and we can't resolve + // sheet-name → sheet-id mid-batch. + { + "+filter-update with --sheet-name only (filter_id must equal sheet_id)", + "+filter-update", + `{"sheet-name":"Sheet1","range":"A1:F1000","properties":{"rules":[]}}`, + "+filter-update requires --sheet-id", + }, + { + "+filter-delete with --sheet-name only (filter_id must equal sheet_id)", + "+filter-delete", + `{"sheet-name":"Sheet1"}`, + "+filter-delete requires --sheet-id", + }, + // +sparkline-update requires sparkline_id on every + // properties.sparklines[i] (server contract). CLI surfaces this + // with a pointer to +sparkline-list so the agent doesn't have to + // guess the id from an opaque server-side rejection. + { + "+sparkline-update item missing sparkline_id", + "+sparkline-update", + `{"sheet-id":"sh1","group-id":"g1","properties":{"sparklines":[{"position":{"row":0,"col":"A"}}]}}`, + "missing sparkline_id", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{ + "shortcut": tc.subShortcut, + "input": subInput, + } + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains) + } + if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains) + } + }) + } +} + +// TestBatchOp_SchemaValidatesSubOps confirms the schema-driven +// validator fires on +batch-update sub-operations the same way it +// fires on standalone shortcuts. mapFlagView.Command() returns the +// sub-op's shortcut name, so validateInputAgainstSchema (called at +// each input builder's tail) routes through the same (command, flag) +// lookup pipeline a standalone invocation would. This regression +// pins that wiring — without it, agents could slip past CLI-side +// schema checks by wrapping a bad input in +batch-update. +func TestBatchOp_SchemaValidatesSubOps(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + subShortcut string + subInput string + wantContains string + }{ + // +pivot-create properties.values items enforce summarize_by + // enum — schema rejects an out-of-enum value as a sub-op too. + { + "+pivot-create summarize_by out of enum", + "+pivot-create", + `{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`, + "summarize_by", + }, + // +chart-create properties.position.row has minimum:0 — P0 + // addition; validator must catch -1 even in the batch path. + { + "+chart-create position.row below minimum", + "+chart-create", + `{"sheet-id":"sh1","properties":{"position":{"row":-1,"col":"A"},"size":{"width":400,"height":300}}}`, + "below minimum", + }, + // +cells-set --cells is a 2D array of objects per the + // upstream-fixed schema; sub-op passing an object must be + // rejected at the schema layer (not "expected JSON array"). + { + "+cells-set cells wrong shape", + "+cells-set", + `{"sheet-id":"sh1","range":"A1","cells":{"foo":"bar"}}`, + `expected type "array"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + rawOp := map[string]interface{}{ + "shortcut": tc.subShortcut, + "input": subInput, + } + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains) + } + if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains) + } + }) + } +} + +// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two +// originally reported failures: +range-copy and +rows-resize sub-ops must +// translate to the correct MCP body (not a near-passthrough that drops +// required fields). +func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) { + t.Parallel() + + // +range-copy → transform_range with range / destination_range (not the + // raw source_range / target_range that used to leak through). + body := parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`, + "--yes", + }) + ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) + copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{}) + if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" { + t.Errorf("+range-copy sub-op body wrong: %#v", copyIn) + } + if copyIn["operation"] != "copy" { + t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"]) + } + + // +rows-resize → resize_range with range + resize_height. The CLI's single + // "23" input must be expanded to "23:23" because resize_range rejects + // 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}}]`, + "--yes", + }) + ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) + resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{}) + if resizeIn["range"] != "23:23" { + t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"]) + } + rh, _ := resizeIn["resize_height"].(map[string]interface{}) + if rh == nil || rh["type"] != "pixel" { + t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn) + } +} + +// TestBatchOp_RequiredFlagParity is the systematic standalone-vs-batch parity +// contract: for EVERY batchable shortcut, a +batch-update sub-op that satisfies +// the sheet locator but omits all of the shortcut's business-required flags must +// fail in translateBatchOp — never silently fall back to a default. The earlier +// cases (TestBatchOp_ErrorEquivalence / GuardsBeyondCobra) cover hand-picked +// shortcuts; this one is data-driven over batchOpDispatch + flag-defs, so it +// guards the whole surface and auto-covers any shortcut added later. If a future +// refactor moves a required check out of the shared *Input builder (the exact +// failure mode behind the csv-put / sheet-move gaps), the corresponding sub-op +// would start accepting missing args and this test fails. +func TestBatchOp_RequiredFlagParity(t *testing.T) { + t.Parallel() + defs, err := loadFlagDefs() + if err != nil { + t.Fatalf("loadFlagDefs: %v", err) + } + // Flags supplied by the +batch-update top level (url/token), or that form the + // sub-op's own sheet selector, are context — not "business" inputs. + locator := map[string]bool{ + "url": true, "spreadsheet-token": true, + "sheet-id": true, "sheet-name": true, + "target-sheet-id": true, "target-sheet-name": true, + } + // How each command expresses its sheet locator in a sub-op, so the error we + // trigger is the business one, not a missing-locator error. + sheetSel := func(cmd string) map[string]interface{} { + switch cmd { + case "+sheet-create": // create needs no existing-sheet anchor + return map[string]interface{}{} + case "+pivot-create": // placement selector is target-sheet-*; data source is --source + return map[string]interface{}{"target-sheet-id": "sh1"} + default: + return map[string]interface{}{"sheet-id": "sh1"} + } + } + for cmd := range batchOpDispatch { + spec, ok := defs[cmd] + if !ok { + t.Errorf("%s is in batchOpDispatch but has no flag-defs entry", cmd) + continue + } + var business []string + for _, fl := range spec.Flags { + if fl.Kind == "system" || locator[fl.Name] { + continue + } + if fl.Required == "required" || fl.Required == "xor" { + business = append(business, fl.Name) + } + } + if len(business) == 0 { + continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit + } + t.Run(cmd, func(t *testing.T) { + t.Parallel() + rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)} + _, err := translateBatchOp(rawOp, testToken, 0) + if err == nil { + t.Errorf("%s: a sub-op omitting business-required %v was accepted; want an error "+ + "(batch must reject missing required flags, not silently default)", cmd, business) + return + } + // The sub-op DID supply a sheet selector, so a missing-locator error + // would mean the fixture is wrong and the business-required check never + // actually ran — reject that shape so the parity check stays honest. + if strings.Contains(err.Error(), "specify at least one of") { + t.Errorf("%s: got a missing-locator error, not a business-required one (fixture bug): %v", cmd, err) + } + }) + } +} diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go new file mode 100644 index 000000000..271d4eeca --- /dev/null +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -0,0 +1,342 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── +batch-update sub-op dispatch ───────────────────────────────────── +// +// 用户传给 +batch-update --operations 的形态是 CLI 视角的 {shortcut, input}: +// +// [{"shortcut": "+range-copy", "input": {"sheet_id":"...","source-range":"A1:B2","target-range":"A10"}}, ...] +// +// input 里用的是该 shortcut 的 **CLI flag 名**(与 standalone 调用一致;连字符 / +// 下划线两种写法都接受)。底层 MCP batch_update tool 要的是 +// {tool_name, input(MCP body)} —— body 的字段名往往与 CLI flag 名不同 +// (如 +range-copy 的 source-range/target-range 要翻成 range/destination_range)。 +// +// 关键:每个子操作复用 **standalone shortcut 同一套 flag→body translator** +// (那些 *Input 构建函数,现在统一接收 flagView 接口)。这样 batch 子操作 +// 产出的 MCP body 与该 shortcut 单独调用产出的 body 完全一致(由 +// batch-vs-standalone 契约测试保证)。dispatch 表只列**可纳入 atomic batch +// 的 write shortcut**——读操作、fan-out wrapper(+batch-update 自身、 +// +cells-batch-set-style、+cells-batch-clear、+dropdown-{update,delete})一律不放进表里, +// 用户传到 +batch-update 里会被 translator 拒绝。 + +// batchTranslateFn turns a sub-op's CLI-shape input (via flagView) into the MCP +// tool body for the underlying batch_update sub-tool. token is the +// +batch-update top-level spreadsheet token; sheetID/sheetName are the resolved +// sheet selector for this sub-op. The returned body already carries excel_id +// and (where the tool needs one) the operation discriminator — exactly as the +// standalone shortcut would emit. +type batchTranslateFn func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) + +type batchOpMapping struct { + // mcpToolName 是底层 MCP batch_update 接受的 tool_name。 + mcpToolName string + // translate 复用 standalone 的 *Input 构建逻辑,产出 MCP body。 + translate batchTranslateFn +} + +// sheetSelectorFlagsForSubOp returns the (id, name) flag names a +batch-update +// sub-op uses to express its placement / context sheet. Defaults are +// `sheet-id` / `sheet-name`; +pivot-create deviates because its create +// shortcut renamed the placement selector to `target-sheet-id` / +// `target-sheet-name` (the data-source sheet is encoded in --source as +// `'SheetName'!Range`, not in a sheet selector flag). Update / delete on +// pivot still use the default names — only the create create-side +// shortcut was renamed. +func sheetSelectorFlagsForSubOp(shortcut string) (string, string) { + if shortcut == "+pivot-create" { + return "target-sheet-id", "target-sheet-name" + } + return "sheet-id", "sheet-name" +} + +// objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object +// CRUD spec to the shared object_crud builders. +func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectCreateInput(fv, token, sheetID, sheetName, spec) + } +} + +func objUpdateTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectUpdateInput(fv, token, sheetID, sheetName, spec) + } +} + +func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectDeleteInput(fv, token, sheetID, sheetName, spec) + } +} + +// batchOpDispatch covers every write shortcut that can join an atomic batch. +// Each entry plugs the shortcut's standalone xxxInput builder into the +// batch translator path — so the body is byte-identical to the standalone +// invocation (locked by TestBatchOp_BodyMatchesStandalone) and the missing- +// flag error is identical too (locked by TestBatchOp_ErrorEquivalence). +var batchOpDispatch = map[string]batchOpMapping{ + // ─── 单元格内容 ────────────────────────────────────────────────── + "+cells-set": {"set_cell_range", cellsSetInput}, + "+cells-set-style": {"set_cell_range", cellsSetStyleInput}, + "+cells-clear": {"clear_cell_range", cellsClearInput}, + "+cells-replace": {"replace_data", replaceInput}, + "+csv-put": {"set_range_from_csv", csvPutInput}, + "+dropdown-set": {"set_cell_range", dropdownSetInput}, + + // ─── 单元格合并 (merge_cells, operation 区分) ──────────────────── + "+cells-merge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return mergeInput(fv, token, sid, sname, "merge", true) + }}, + "+cells-unmerge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return mergeInput(fv, token, sid, sname, "unmerge", false) + }}, + + // ─── 行列结构 (modify_sheet_structure, operation 区分) ────────── + "+dim-insert": {"modify_sheet_structure", dimInsertInput}, + "+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "delete") + }}, + "+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "hide") + }}, + "+dim-unhide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "unhide") + }}, + "+dim-freeze": {"modify_sheet_structure", dimFreezeInput}, + "+dim-group": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimGroupInput(fv, token, sid, sname, "group") + }}, + "+dim-ungroup": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimGroupInput(fv, token, sid, sname, "ungroup") + }}, + + // ─── 行高列宽 (resize_range, 无 operation 字段) ───────────────── + "+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return resizeInput(fv, token, sid, sname, "row") + }}, + "+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return resizeInput(fv, token, sid, sname, "column") + }}, + + // ─── 区域操作 (transform_range, operation 区分) ───────────────── + "+range-move": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return transformMoveCopyInput(fv, token, sid, sname, "move", false) + }}, + "+range-copy": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return transformMoveCopyInput(fv, token, sid, sname, "copy", true) + }}, + "+range-fill": {"transform_range", rangeFillInput}, + "+range-sort": {"transform_range", rangeSortInput}, + + // ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ── + "+sheet-create": {"modify_workbook_structure", func(fv flagView, token, _, _ string) (map[string]interface{}, error) { + return sheetCreateInput(fv, token) + }}, + "+sheet-delete": {"modify_workbook_structure", sheetDeleteInput}, + "+sheet-rename": {"modify_workbook_structure", sheetRenameInput}, + "+sheet-move": {"modify_workbook_structure", sheetMoveBatchInput}, + "+sheet-copy": {"modify_workbook_structure", sheetCopyInput}, + "+sheet-hide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) { + return sheetVisibilityInput(fv, t, sid, sn, "hide") + }}, + "+sheet-unhide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) { + return sheetVisibilityInput(fv, t, sid, sn, "unhide") + }}, + "+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput}, + + // ─── 对象族 CRUD (manage_*_object, operation 区分) ───────────── + "+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)}, + "+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)}, + "+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)}, + + "+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)}, + "+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)}, + "+pivot-delete": {"manage_pivot_table_object", objDeleteTranslate(pivotSpec)}, + + "+cond-format-create": {"manage_conditional_format_object", objCreateTranslate(condFormatSpec)}, + "+cond-format-update": {"manage_conditional_format_object", objUpdateTranslate(condFormatSpec)}, + "+cond-format-delete": {"manage_conditional_format_object", objDeleteTranslate(condFormatSpec)}, + + "+filter-create": {"manage_filter_object", filterCreateInput}, + "+filter-update": {"manage_filter_object", filterUpdateInput}, + "+filter-delete": {"manage_filter_object", filterDeleteInput}, + + "+filter-view-create": {"manage_filter_view_object", objCreateTranslate(filterViewSpec)}, + "+filter-view-update": {"manage_filter_view_object", objUpdateTranslate(filterViewSpec)}, + "+filter-view-delete": {"manage_filter_view_object", objDeleteTranslate(filterViewSpec)}, + + "+sparkline-create": {"manage_sparkline_object", objCreateTranslate(sparklineSpec)}, + "+sparkline-update": {"manage_sparkline_object", objUpdateTranslate(sparklineSpec)}, + "+sparkline-delete": {"manage_sparkline_object", objDeleteTranslate(sparklineSpec)}, + + "+float-image-create": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + if err := rejectLocalImageInBatch(fv); err != nil { + return nil, err + } + return floatImageWriteInput(fv, token, sid, sname, "create", false, "") + }}, + "+float-image-update": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + if err := rejectLocalImageInBatch(fv); err != nil { + return nil, err + } + return floatImageWriteInput(fv, token, sid, sname, "update", true, "") + }}, + "+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)}, +} + +// 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. +func rejectLocalImageInBatch(fv flagView) error { + if strings.TrimSpace(fv.Str("image")) != "" { + return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead") + } + return nil +} + +// sheetMoveBatchInput translates +sheet-move inside a batch. Unlike the +// standalone shortcut it cannot issue the get_workbook_structure read that +// auto-derives sheet_id / source_index, so both must be supplied explicitly. +func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if sheetID == "" { + return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)") + } + if !fv.Changed("source-index") { + return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)") + } + if fv.Int("source-index") < 0 { + return nil, common.FlagErrorf("--source-index must be >= 0") + } + // Standalone +sheet-move requires --index (see SheetMove.Validate). A batch + // sub-op skips that path, and mapFlagView falls back to the flag default (0), + // which would silently move the sheet to the front. Require it explicitly so + // the batch contract matches the standalone one. + if !fv.Changed("index") { + return nil, common.FlagErrorf("+sheet-move in +batch-update requires index") + } + if fv.Int("index") < 0 { + return nil, common.FlagErrorf("--index must be >= 0") + } + return map[string]interface{}{ + "excel_id": token, + "operation": "move", + "sheet_id": sheetID, + "source_index": fv.Int("source-index"), + "target_index": fv.Int("index"), + }, nil +} + +// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们由 +// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。 +var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"} + +// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP +// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用 +// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone +// translator 翻成 MCP body。 +// +// 失败场景: +// - shortcut 字段缺失 / 非 string +// - shortcut 不在 dispatch 表(拼写错;read 操作;嵌套 fan-out wrapper) +// - input 不是 object +// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch) +// - input 里手填了 excel_id / spreadsheet_token / url +// - 子操作的 translator 报错(如缺必填字段) +func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) { + op, ok := raw.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("operations[%d] must be a JSON object", index) + } + scRaw, present := op["shortcut"] + if !present { + return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index) + } + sc, ok := scRaw.(string) + if !ok || sc == "" { + return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw) + } + mapping, ok := batchOpDispatch[sc] + if !ok { + return nil, common.FlagErrorf( + "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)", + index, sc, + ) + } + inputRaw, hasInput := op["input"] + var input map[string]interface{} + if !hasInput || inputRaw == nil { + input = map[string]interface{}{} + } else { + input, ok = inputRaw.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw) + } + } + // 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。 + if _, has := input["operation"]; has { + return nil, common.FlagErrorf( + "operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name", + index, sc, + ) + } + // 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。 + for _, k := range reservedSubOpKeys { + if _, has := input[k]; has { + return nil, common.FlagErrorf( + "operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token", + index, sc, k, + ) + } + } + // 拒绝任何额外的 sub-op 顶层 key(防御未来 schema drift / 用户笔误)。 + for k := range op { + if k != "shortcut" && k != "input" { + return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k) + } + } + fv := newMapFlagViewForCommand(sc, input) + // operations is skipped by parse-time schema validation, so type-check the + // sub-op's scalar fields here before the translator reads them via + // Int/Bool/Float64 (which would otherwise coerce a wrong type to zero). + if err := fv.validateRawTypes(); err != nil { + return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err) + } + sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc) + sheetID := strings.TrimSpace(fv.Str(sheetIDFlag)) + sheetName := strings.TrimSpace(fv.Str(sheetNameFlag)) + body, err := mapping.translate(fv, token, sheetID, sheetName) + if err != nil { + return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err) + } + return map[string]interface{}{ + "tool_name": mapping.mcpToolName, + "input": body, + }, nil +} + +// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。 +func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) { + if len(rawOps) == 0 { + return nil, common.FlagErrorf("--operations must be a non-empty JSON array") + } + out := make([]interface{}, 0, len(rawOps)) + for i, raw := range rawOps { + translated, err := translateBatchOp(raw, token, i) + if err != nil { + return nil, err + } + out = append(out, translated) + } + return out, nil +} diff --git a/shortcuts/sheets/csv_put_range_alias_test.go b/shortcuts/sheets/csv_put_range_alias_test.go new file mode 100644 index 000000000..4a631d6f6 --- /dev/null +++ b/shortcuts/sheets/csv_put_range_alias_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" +) + +// +csv-put locates with --start-cell, while +csv-get / +cells-set locate with +// --range. Agents routinely carry --range over to +csv-put and hit a guaranteed +// first-try failure. csvPutInput now accepts --range as an alias for +// --start-cell; a range value collapses to its top-left cell. +func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) { + tests := []struct { + name string + raw map[string]interface{} + wantAnchor string + }{ + {"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"}, + {"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"}, + {"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"}, + {"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fv := newMapFlagViewForCommand("+csv-put", tt.raw) + input, err := csvPutInput(fv, "tok", "sid", "") + if err != nil { + t.Fatalf("csvPutInput returned error: %v", err) + } + got, _ := input["start_cell"].(string) + if got != tt.wantAnchor { + t.Errorf("start_cell = %q, want %q", got, tt.wantAnchor) + } + }) + } +} + +// With neither --start-cell nor --range explicitly set, csvPutInput rejects the +// call instead of silently anchoring at the "A1" flag default. Standalone never +// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it +// first — but a +batch-update sub-op skips cobra, so the guard must live in the +// shared builder too. Otherwise a batch +csv-put with no anchor silently pastes +// at A1, diverging from the standalone contract. +func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) { + fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"}) + _, err := csvPutInput(fv, "tok", "sid", "") + if err == nil { + t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error") + } + if !strings.Contains(err.Error(), "--start-cell or --range is required") { + t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error()) + } +} + +// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see +// how far a CSV reaches from its anchor — it auto-expands to the CSV's own size, +// not to any user-set range. +func TestCsvPutWriteRangeFromInput(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want string + ok bool + }{ + {"3x3 at B2", map[string]interface{}{"start_cell": "B2", "csv": "a,b,c\n1,2,3\n4,5,6"}, "B2:D4", true}, + {"single cell at A1", map[string]interface{}{"start_cell": "A1", "csv": "x"}, "A1:A1", true}, + {"1 row 3 cols at C3", map[string]interface{}{"start_cell": "C3", "csv": "a,b,c"}, "C3:E3", true}, + {"ragged rows use max width", map[string]interface{}{"start_cell": "A1", "csv": "a,b\nc,d,e"}, "A1:C2", true}, + {"missing csv", map[string]interface{}{"start_cell": "A1"}, "", false}, + {"non-single anchor", map[string]interface{}{"start_cell": "A1:B2", "csv": "x"}, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := csvPutWriteRangeFromInput(tt.input) + if ok != tt.ok || got != tt.want { + t.Errorf("got (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok) + } + }) + } +} diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json new file mode 100644 index 000000000..7a9fe4dc8 --- /dev/null +++ b/shortcuts/sheets/data/flag-defs.json @@ -0,0 +1,4542 @@ +{ + "+workbook-info": { + "risk": "read", + "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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-create": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "title", + "kind": "own", + "type": "string", + "required": "required", + "desc": "New sheet title" + }, + { + "name": "index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Insert position; 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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm high-risk write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-rename": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "title", + "kind": "own", + "type": "string", + "required": "required", + "desc": "New title" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-move": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "index", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Target position (0-based)" + }, + { + "name": "source-index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", + "default": "-1" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-copy": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "title", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Copy title; auto-generated by the server when omitted" + }, + { + "name": "index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Insert position for the copy (0-based); appended to the end when omitted", + "default": "-1" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-hide": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-unhide": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-set-tab-color": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "color", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Hex color like `#FF0000`; pass empty string `\"\"` to clear" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+workbook-create": { + "risk": "write", + "flags": [ + { + "name": "title", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Spreadsheet title" + }, + { + "name": "folder-token", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Target folder token; placed at the drive root when omitted" + }, + { + "name": "headers", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "values", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+workbook-export": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "file-extension", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Export file format; `csv` mode requires `--sheet-id`", + "default": "xlsx", + "enum": [ + "xlsx", + "csv" + ] + }, + { + "name": "sheet-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)" + }, + { + "name": "output-path", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Local save path; export is triggered but not downloaded when omitted" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sheet-info": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "include", + "kind": "own", + "type": "string_slice", + "required": "optional", + "desc": "Comma-separated structure info categories to return", + "enum": [ + "merges", + "row_heights", + "col_widths", + "hidden_rows", + "hidden_cols", + "groups", + "frozen" + ] + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Limit structure info to this A1 range; whole sheet when omitted" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-insert": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "inherit-style", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", + "default": "none", + "enum": [ + "before", + "after", + "none" + ] + }, + { + "name": "position", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position" + }, + { + "name": "count", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Number of rows/columns to insert (must be > 0)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-hide": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-unhide": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-freeze": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dimension", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Dimension (row or column)", + "enum": [ + "row", + "column" + ] + }, + { + "name": "count", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Freeze the first N rows/columns; pass 0 to unfreeze" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-group": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "depth", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Nesting level for grouping; default 1", + "default": "1" + }, + { + "name": "group-state", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Initial group expand state", + "default": "expand", + "enum": [ + "expand", + "fold" + ] + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-ungroup": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "depth", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Group nesting level to ungroup; default 1 (outermost)", + "default": "1" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dim-move": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "source-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, + { + "name": "target", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-get": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)" + }, + { + "name": "include", + "kind": "own", + "type": "string_slice", + "required": "optional", + "desc": "Comma-separated info categories to include", + "enum": [ + "value", + "formula", + "style", + "comment", + "data_validation" + ] + }, + { + "name": "max-chars", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 200000", + "default": "200000", + "hidden": true + }, + { + "name": "skip-hidden", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Skip hidden rows and columns; default `false`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dropdown-get": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+csv-get": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)" + }, + { + "name": "max-chars", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 200000", + "default": "200000", + "hidden": true + }, + { + "name": "include-row-prefix", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Whether to prefix each row with `[row=N]`; default `true`", + "default": "true" + }, + { + "name": "skip-hidden", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Skip hidden rows and columns; default `false`" + }, + { + "name": "rows-json", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", + "default": "false" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Print the request path and parameters without executing" + } + ] + }, + "+cells-search": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "find", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Text to find (interpreted as regex when `--regex` is set)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Search range (A1 notation); whole sheet when omitted" + }, + { + "name": "match-case", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Case-sensitive match" + }, + { + "name": "match-entire-cell", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Match the entire cell content" + }, + { + "name": "regex", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Interpret `--find` as a regex pattern" + }, + { + "name": "include-formulas", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Also search within formula text" + }, + { + "name": "max-matches", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 5000", + "default": "5000", + "hidden": true + }, + { + "name": "offset", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Skip the first N matches (for pagination); default 0", + "default": "0" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-replace": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "find", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Text to find for replacement" + }, + { + "name": "replacement", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Replacement text; pass empty string `\"\"` to delete matched content" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Replace range (A1 notation); whole sheet when omitted" + }, + { + "name": "match-case", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Case-sensitive match" + }, + { + "name": "match-entire-cell", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Match the entire cell content" + }, + { + "name": "regex", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Interpret `--find` as a regex pattern" + }, + { + "name": "include-formulas", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Also replace within formula text" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace" + } + ] + }, + "+cells-set": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Write range (A1 notation)" + }, + { + "name": "cells", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "allow-overwrite", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", + "default": "true" + }, + { + "name": "max-cells", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Safety cap; default 50000", + "default": "50000", + "hidden": true + }, + { + "name": "copy-to-range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'." + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-set-style": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target range (A1 notation, e.g. `A1:B2`)" + }, + { + "name": "background-color", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Background color (hex, e.g. `#ffffff`)" + }, + { + "name": "font-color", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font color (hex, e.g. `#000000`)" + }, + { + "name": "font-size", + "kind": "own", + "type": "float64", + "required": "optional", + "desc": "Font size in px (e.g. 10, 12, 14)" + }, + { + "name": "font-style", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font style", + "enum": [ + "normal", + "italic" + ] + }, + { + "name": "font-weight", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font weight", + "enum": [ + "normal", + "bold" + ] + }, + { + "name": "font-line", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font line style", + "enum": [ + "none", + "underline", + "line-through" + ] + }, + { + "name": "horizontal-alignment", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Horizontal alignment", + "enum": [ + "left", + "center", + "right" + ] + }, + { + "name": "vertical-alignment", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Vertical alignment", + "enum": [ + "top", + "middle", + "bottom" + ] + }, + { + "name": "word-wrap", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Word-wrap strategy", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ] + }, + { + "name": "number-format", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)" + }, + { + "name": "border-styles", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-set-image": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)" + }, + { + "name": "image", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)" + }, + { + "name": "name", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Image file name (with extension); defaults to the basename of `--image`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dropdown-set": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target range (A1 notation, e.g. `A2:A100`)" + }, + { + "name": "options", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "colors", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "multiple", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Enable multi-select; default `false`" + }, + { + "name": "highlight", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`." + }, + { + "name": "source-range", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress." + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+csv-put": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "start-cell", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", + "default": "A1" + }, + { + "name": "csv", + "kind": "own", + "type": "string", + "required": "required", + "desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "allow-overwrite", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Allow overwriting (default true); set false to error if any target cell is non-empty", + "default": "true" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", + "hidden": true + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-clear": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range to clear (A1 notation)" + }, + { + "name": "scope", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", + "default": "content", + "enum": [ + "content", + "formats", + "all" + ] + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); clear is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-merge": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range to merge / unmerge (A1 notation)" + }, + { + "name": "merge-type", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Merge direction (`+cells-merge` only)", + "default": "all", + "enum": [ + "all", + "rows", + "columns" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-unmerge": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range to merge / unmerge (A1 notation)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+rows-resize": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "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": [ + "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": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cols-resize": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "type", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column 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)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+range-move": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "source-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Source A1 range" + }, + { + "name": "target-sheet-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Destination sub-sheet id; defaults to the same sheet as the source" + }, + { + "name": "target-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Destination A1 range (anchor cell is enough; size inferred from the source)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+range-copy": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "source-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Source A1 range" + }, + { + "name": "target-sheet-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Destination sub-sheet id; defaults to the same sheet as the source" + }, + { + "name": "target-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Destination A1 range (anchor cell is enough; size inferred from the source)" + }, + { + "name": "paste-type", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Paste content type (`+range-copy` only)", + "default": "all", + "enum": [ + "values", + "formulas", + "formats", + "all" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+range-fill": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "source-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Fill template range (seed cells for the series)" + }, + { + "name": "target-range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Destination fill range (A1 notation)" + }, + { + "name": "series-type", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Fill series type", + "default": "auto", + "enum": [ + "auto", + "linear", + "growth", + "date", + "copy" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+range-sort": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Sort range (A1 notation; whether the header is included depends on `--has-header`)" + }, + { + "name": "sort-keys", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON array: `[{\"column\":\"\",\"ascending\":}, ...]`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "has-header", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Treat the first row as a header and exclude from sort; default `false`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+batch-update": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator (independent from per-operation sheet locator)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet locator (independent from per-operation sheet locator)" + }, + { + "name": "operations", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets --help; for composite JSON flags use --print-schema --flag-name . Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "continue-on-error", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm high-risk write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Print the request template for each sub-operation; no network side effects" + } + ] + }, + "+cells-batch-set-style": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "background-color", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Background color (hex, e.g. `#ffffff`)" + }, + { + "name": "font-color", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font color (hex, e.g. `#000000`)" + }, + { + "name": "font-size", + "kind": "own", + "type": "float64", + "required": "optional", + "desc": "Font size in px (e.g. 10, 12, 14)" + }, + { + "name": "font-style", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font style", + "enum": [ + "normal", + "italic" + ] + }, + { + "name": "font-weight", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font weight", + "enum": [ + "normal", + "bold" + ] + }, + { + "name": "font-line", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Font line style", + "enum": [ + "none", + "underline", + "line-through" + ] + }, + { + "name": "horizontal-alignment", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Horizontal alignment", + "enum": [ + "left", + "center", + "right" + ] + }, + { + "name": "vertical-alignment", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Vertical alignment", + "enum": [ + "top", + "middle", + "bottom" + ] + }, + { + "name": "word-wrap", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Word-wrap strategy", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ] + }, + { + "name": "number-format", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)" + }, + { + "name": "border-styles", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Border config JSON (same shape as in +cells-set-style)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dropdown-update": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "options", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "colors", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "multiple", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Enable multi-select" + }, + { + "name": "highlight", + "kind": "own", + "type": "bool", + "required": "optional", + "desc": "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`." + }, + { + "name": "source-range", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress." + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+dropdown-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm high-risk write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cells-batch-clear": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "scope", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", + "default": "content", + "enum": [ + "content", + "formats", + "all" + ] + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+chart-list": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "chart-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter to a single chart reference_id" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+chart-create": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "Print the request template; no side effects" + } + ] + }, + "+chart-update": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "chart-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target chart reference_id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+chart-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "chart-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target chart reference_id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+pivot-list": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "pivot-table-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by id" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+pivot-create": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "target-position", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", + "default": "A1" + }, + { + "name": "target-sheet-id", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100." + }, + { + "name": "target-sheet-name", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100." + }, + { + "name": "source", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both." + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+pivot-update": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "pivot-table-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target pivot table id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id ` first, then patch)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+pivot-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "pivot-table-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target pivot table id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cond-format-list": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "rule-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by rule id" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cond-format-create": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "rule-type", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Conditional format rule type; takes precedence over the same-named field inside `--properties`", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ] + }, + { + "name": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cond-format-update": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "rule-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target rule id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "rule-type", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Conditional format rule type; takes precedence over the same-named field inside `--properties`", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ] + }, + { + "name": "ranges", + "kind": "own", + "type": "string", + "required": "required", + "desc": "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+cond-format-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "rule-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target rule id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-list": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-create": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-update": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-view-list": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "view-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by filter-view reference_id (returns the matching single view)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-view-create": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row" + }, + { + "name": "view-name", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-view-update": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "view-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target filter-view reference_id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update" + }, + { + "name": "view-name", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+filter-view-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "view-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target filter-view reference_id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm high-risk write (exit code 10 without this flag)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sparkline-list": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "group-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by group_id" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sparkline-create": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sparkline-update": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "group-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target group id" + }, + { + "name": "properties", + "kind": "own", + "type": "string", + "required": "required", + "desc": "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id ` first, then patch; run `--print-schema` for the full structure", + "input": [ + "file", + "stdin" + ] + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+sparkline-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "group-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target group id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+float-image-list": { + "risk": "read", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "float-image-id", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Filter by id; lists all float images on the sheet when omitted" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+float-image-create": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "image-name", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Image name, including extension (e.g. `logo.png`)" + }, + { + "name": "image-token", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`" + }, + { + "name": "image-uri", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow" + }, + { + "name": "position-row", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Row anchor of the image's top-left corner (0-based)" + }, + { + "name": "position-col", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)" + }, + { + "name": "size-width", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Image width in pixels" + }, + { + "name": "size-height", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Image height in pixels" + }, + { + "name": "offset-row", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Pixel offset within the anchor row, on top of `--position-row`" + }, + { + "name": "offset-col", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Pixel offset within the anchor column, on top of `--position-col`" + }, + { + "name": "z-index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Image z-index controlling stacking order" + }, + { + "name": "image", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+float-image-update": { + "risk": "write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "float-image-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target float image id" + }, + { + "name": "image-name", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Image name, including extension (e.g. `logo.png`)" + }, + { + "name": "image-token", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`" + }, + { + "name": "image-uri", + "kind": "own", + "type": "string", + "required": "xor", + "desc": "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow" + }, + { + "name": "position-row", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Row anchor of the image's top-left corner (0-based)" + }, + { + "name": "position-col", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)" + }, + { + "name": "size-width", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Image width in pixels" + }, + { + "name": "size-height", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Image height in pixels" + }, + { + "name": "offset-row", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Pixel offset within the anchor row, on top of `--position-row`" + }, + { + "name": "offset-col", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Pixel offset within the anchor column, on top of `--position-col`" + }, + { + "name": "z-index", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Image z-index controlling stacking order" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, + "+float-image-delete": { + "risk": "high-risk-write", + "flags": [ + { + "name": "url", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)" + }, + { + "name": "spreadsheet-token", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Spreadsheet token (XOR with `--url`)" + }, + { + "name": "sheet-id", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet reference_id (XOR with `--sheet-name`)" + }, + { + "name": "sheet-name", + "kind": "public", + "type": "string", + "required": "xor", + "desc": "Sheet name (XOR with `--sheet-id`)" + }, + { + "name": "float-image-id", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Target float image id" + }, + { + "name": "yes", + "kind": "system", + "type": "bool", + "required": "required", + "desc": "Confirm destructive write (exit code 10 without this flag); delete is irreversible" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + } +} diff --git a/shortcuts/sheets/data/flag-schemas.json b/shortcuts/sheets/data/flag-schemas.json new file mode 100644 index 000000000..c7aad1edf --- /dev/null +++ b/shortcuts/sheets/data/flag-schemas.json @@ -0,0 +1,6254 @@ +{ + "schema_version": "2", + "flags": { + "+batch-update": { + "operations": { + "type": "array", + "description": "要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断。", + "items": { + "type": "object", + "required": [ + "shortcut", + "input" + ], + "properties": { + "shortcut": { + "type": "string", + "enum": [ + "+cells-set", + "+cells-set-style", + "+cells-clear", + "+cells-merge", + "+cells-unmerge", + "+cells-replace", + "+csv-put", + "+dropdown-set", + "+dim-insert", + "+dim-delete", + "+dim-hide", + "+dim-unhide", + "+dim-freeze", + "+dim-group", + "+dim-ungroup", + "+rows-resize", + "+cols-resize", + "+range-move", + "+range-copy", + "+range-fill", + "+range-sort", + "+sheet-create", + "+sheet-delete", + "+sheet-rename", + "+sheet-move", + "+sheet-copy", + "+sheet-hide", + "+sheet-unhide", + "+sheet-set-tab-color", + "+chart-create", + "+chart-update", + "+chart-delete", + "+pivot-create", + "+pivot-update", + "+pivot-delete", + "+cond-format-create", + "+cond-format-update", + "+cond-format-delete", + "+filter-create", + "+filter-update", + "+filter-delete", + "+filter-view-create", + "+filter-view-update", + "+filter-view-delete", + "+sparkline-create", + "+sparkline-update", + "+sparkline-delete", + "+float-image-create", + "+float-image-update", + "+float-image-delete" + ], + "description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 atomic batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete})一律禁。" + }, + "input": { + "type": "object", + "description": "该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON、不是再套一层嵌套。基础 flag 跑 `lark-cli sheets --help`,复合 JSON flag 跑 `--print-schema --flag-name `。不要手填 `operation`(动作由 shortcut 名表达)。" + } + } + } + } + }, + "+cells-batch-set-style": { + "border-styles": { + "type": "object", + "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", + "properties": { + "top": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "bottom": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "left": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "right": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + } + } + } + }, + "+cells-set": { + "cells": { + "description": "【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]](2行×3列),'B5:B7'→[[_],[_],[_]](3行×1列),'A1'→[[_]](1×1)。不修改的单元格填 {}。【内容字段,只能选一个】value(普通值)/ formula(公式,以 = 开头)/ rich_text(富文本)/ multiple_values(多值)。【可叠加字段,可与内容字段自由组合】cell_styles、border_styles、note、data_validation。【增量】所有字段均为增量更新,未传字段保留原值不变。", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "description": "静态单元格值(文本、数字、布尔)。公式请优先使用 'formula' 字段;如果误把以 '=' 开头的公式字符串写到这里,工具会按 Excel 语义自动识别为公式入库,但仍应按 'formula' 字段的契约传参。", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "formula": { + "description": "以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')。公式必须写在此字段,而不是 'value'。", + "type": "string" + }, + "note": { + "description": "单元格批注/备注。设为 null 可清除已有的批注。", + "type": "string", + "nullable": true + }, + "cell_styles": { + "type": "object", + "properties": { + "font_color": { + "description": "字体颜色(十六进制,例如 \"#000000\")", + "type": "string" + }, + "font_size": { + "description": "字体大小(单位:px/像素,例如 10、12、14)", + "type": "number" + }, + "font_weight": { + "description": "字重", + "type": "string", + "enum": [ + "normal", + "bold" + ] + }, + "font_style": { + "description": "字体样式", + "type": "string", + "enum": [ + "normal", + "italic" + ] + }, + "font_line": { + "description": "字体线条样式", + "type": "string", + "enum": [ + "none", + "underline", + "line-through" + ] + }, + "background_color": { + "description": "背景颜色(十六进制,例如 \"#ffffff\")", + "type": "string" + }, + "horizontal_alignment": { + "description": "水平对齐方式", + "type": "string", + "enum": [ + "left", + "center", + "right" + ] + }, + "vertical_alignment": { + "description": "垂直对齐方式", + "type": "string", + "enum": [ + "top", + "middle", + "bottom" + ] + }, + "number_format": { + "description": "数字格式(例如:文本用 \"@\"、数字用 \"0.00\"、货币用 \"$#,##0.00\"、日期用 \"mm/dd/yyyy\")", + "type": "string" + }, + "word_wrap": { + "description": "是否自动换行,默认溢出,可选自动换行或裁剪。", + "type": "string", + "enum": [ + "overflow", + "auto-wrap", + "word-clip" + ] + } + }, + "description": "单元格样式属性,包括字体、颜色、对齐方式和数字格式" + }, + "border_styles": { + "type": "object", + "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", + "properties": { + "top": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "bottom": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "left": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "right": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + } + } + }, + "rich_text": { + "description": "富文本内容。设置后会忽略 value 字段。可包含带样式的文本段 text、超链接 link、@提及 mention、单元格图片 embed-image、附件 attachment。", + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "description": "段类型", + "type": "string", + "enum": [ + "text", + "link", + "mention", + "embed-image", + "attachment" + ] + }, + "text": { + "description": "显示文本", + "type": "string" + }, + "style": { + "description": "文本段样式(仅 type='text' 时生效),结构同 cell_styles", + "type": "object" + }, + "link": { + "description": "超链接地址(仅 type='link' 时必填)", + "type": "string" + }, + "mention_token": { + "description": "@提及目标的 token,如 userId 或 fileId(仅 type='mention' 时必填)", + "type": "string" + }, + "mention_type": { + "description": "@提及类型编号(仅 type='mention' 时可选)", + "type": "number" + }, + "notify": { + "description": "是否发送通知(仅 type='mention' 时可选,默认 true)", + "type": "boolean" + }, + "image_width": { + "description": "图片宽度(像素,仅 type='embed-image' 时使用)", + "type": "number" + }, + "image_height": { + "description": "图片高度(像素,仅 type='embed-image' 时使用)", + "type": "number" + }, + "image_name": { + "description": "图片名称(仅 type='embed-image' 时使用,创建新图片时必填)", + "type": "string" + }, + "image_uri": { + "description": "图片文件 reference_id(仅 type='embed-image' 时使用,与 image_token 二选一,如`<|image|>:abcdef` 或者 `<|superscript|>:abcdef-<|image|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "type": "string" + }, + "image_token": { + "description": "图片文件 token(仅 type='embed-image' 时可选,修改已有图片时可从 get_cell_range 获取)", + "type": "string" + }, + "attachment_token": { + "description": "附件文件 token,通过飞书 Drive 上传获取(仅 type='attachment' 时可选,修改已有附件时可从 get_cell_range 获取)", + "type": "string" + }, + "attachment_uri": { + "description": "附件文件 reference_id(仅 type='attachment' 时使用,与 attachment_token 二选一,如`<|attachment|>:abcdef` 或者 `<|superscript|>:abcdef-<|attachment|>:abcdef`,其中 `abcdef` 为实际的对象 ID,占位符仅用于示意)", + "type": "string" + }, + "attachment_name": { + "description": "附件文件名称(仅 type='attachment' 时使用,配合 attachment_reference_id 使用,创建新附件时必填)", + "type": "string" + }, + "mime_type": { + "description": "附件 MIME 类型(仅 type='attachment' 时使用,例如 'application/pdf')", + "type": "string" + }, + "file_size": { + "description": "附件文件大小(字节,仅 type='attachment' 时使用)", + "type": "number" + } + }, + "required": [ + "type", + "text" + ] + } + }, + "multiple_values": { + "description": "多值内容,用于支持多选的列表验证单元格。设置后会忽略 value 和 rich_text 字段。", + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "description": "值(文本、数字、布尔)", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "format": { + "description": "可选的数字格式(例如 '$#,##0.00')", + "type": "string" + } + }, + "required": [ + "value" + ] + } + }, + "data_validation": { + "description": "数据验证配置。设为 null 可清除已有的数据验证。", + "type": "object", + "nullable": true, + "properties": { + "type": { + "description": "数据验证类型:list(下拉列表)、listFromRange(引用范围下拉列表)、number(数字)、date(日期)、textLength(文本长度)、checkbox(复选框)", + "type": "string", + "enum": [ + "list", + "listFromRange", + "number", + "date", + "textLength", + "checkbox" + ] + }, + "items": { + "description": "列表选项", + "type": "array", + "items": { + "type": "string" + } + }, + "range": { + "description": "源数据区域(type='listFromRange' 时必填,格式:'SheetName!A1:A10')", + "type": "string" + }, + "operator": { + "description": "比较运算符(type='number'/'date'/'textLength' 时必填)", + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ] + }, + "values": { + "description": "比较值(operator 为 'between'/'notBetween' 时需要两个值,其它运算符需要一个值)", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "support_multiple_values": { + "description": "列表验证是否支持多选(type='list'/'listFromRange' 时可选,默认 false)", + "type": "boolean" + }, + "help_text": { + "description": "验证失败时显示的提示文本", + "type": "string" + }, + "enable_highlight": { + "description": "是否开启下拉选项的胶囊背景色高亮,仅 type='list'/'listFromRange' 生效。默认 true,自动按内置 10 色色板循环上色。仅当用户明确要求纯色下拉时才传 false。当用户要求按下拉项分别染色时,用本字段 + highlight_colors 一步搞定即可,不要走条件格式(条件格式是染整格背景,语义不符)。", + "type": "boolean" + }, + "highlight_colors": { + "description": "下拉选项的胶囊背景色数组(十六进制,例如 [\"#FFE699\",\"#bff7d9\",\"#ffb3b3\"])。按顺序对应(type='list' 对应 items;type='listFromRange' 按 range 内单元格行优先顺序,如 A1:A10 对应第 1-10 项;A1:B2 顺序为 A1,B1,A2,B2)。长度可以短于但不能长于;未指定项及不提供该字段时按内置 10 色色板循环补色。当 enable_highlight=false 时本字段被忽略。", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ] + } + } + } + } + } + }, + "+cells-set-style": { + "border-styles": { + "type": "object", + "description": "单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)。", + "properties": { + "top": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "bottom": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "left": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + }, + "right": { + "type": "object", + "properties": { + "style": { + "description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)", + "type": "string", + "enum": [ + "solid", + "dashed", + "dotted", + "double", + "none" + ] + }, + "weight": { + "description": "边框粗细/线宽", + "type": "string", + "enum": [ + "thin", + "medium", + "thick" + ] + }, + "color": { + "description": "边框颜色(十六进制,例如 \"#000000\")", + "type": "string" + } + } + } + } + } + }, + "+chart-create": { + "properties": { + "description": "创建/更新的图表属性。", + "type": "object", + "properties": { + "position": { + "type": "object", + "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", + "properties": { + "row": { + "type": "number", + "minimum": 0, + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" + } + }, + "required": [ + "row", + "col" + ] + }, + "offset": { + "type": "object", + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "row_offset": { + "type": "number", + "description": "行偏移量(像素)" + }, + "col_offset": { + "type": "number", + "description": "列偏移量(像素)" + } + } + }, + "size": { + "type": "object", + "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", + "properties": { + "width": { + "type": "number", + "minimum": 10, + "description": "宽度(像素)" + }, + "height": { + "type": "number", + "minimum": 10, + "description": "高度(像素)" + } + }, + "required": [ + "width", + "height" + ] + }, + "snapshot": { + "type": "object", + "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", + "properties": { + "title": { + "type": "object", + "description": "图表标题配置", + "properties": { + "text": { + "type": "string", + "description": "标题文本" + }, + "textAlign": { + "type": "string", + "description": "标题对齐方式", + "enum": [ + "left", + "center", + "right" + ] + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + }, + "required": [ + "text" + ] + }, + "subTitle": { + "type": "object", + "description": "图表副标题配置", + "properties": { + "text": { + "type": "string", + "description": "副标题文本" + }, + "textAlign": { + "type": "string", + "description": "副标题对齐方式", + "enum": [ + "left", + "center", + "right" + ] + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + }, + "required": [ + "text" + ] + }, + "style": { + "type": "object", + "description": "图表样式配置", + "properties": { + "background": { + "type": "object", + "description": "背景配置", + "properties": { + "color": { + "type": "string", + "description": "背景颜色,格式为 #RRGGBB" + } + } + }, + "font": { + "type": "object", + "description": "字体配置", + "properties": { + "size": { + "type": "number", + "description": "字体大小" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + } + }, + "border": { + "type": "object", + "description": "边框配置", + "properties": { + "color": { + "type": "string", + "description": "边框颜色,格式为 #RRGGBB" + }, + "width": { + "type": "number", + "description": "边框宽度" + }, + "style": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "radius": { + "type": "number", + "description": "边框圆角" + } + } + }, + "colorTheme": { + "type": "array", + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ] + } + }, + { + "minItems": 2, + "items": { + "type": "string", + "description": "颜色字符串,十六进制格式:#RRGGBB" + } + } + ] + }, + "colorGradient": { + "type": "boolean", + "description": "是否启用颜色渐变" + } + } + }, + "legend": { + "oneOf": [ + { + "type": "object", + "description": "图例配置", + "properties": { + "position": { + "type": "string", + "description": "图例位置", + "enum": [ + "top", + "bottom", + "left", + "right" + ] + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + } + }, + { + "type": "boolean", + "description": "false 表示隐藏图例" + } + ] + }, + "plotArea": { + "type": "object", + "description": "绘图区域配置", + "properties": { + "plot": { + "type": "object", + "description": "绘图配置", + "properties": { + "type": { + "type": "string", + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ] + }, + "comboType": { + "type": "string", + "description": "组合图表默认类型", + "enum": [ + "column", + "line", + "area" + ] + }, + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] + }, + "extra": { + "type": "object", + "description": "额外配置", + "properties": { + "smooth": { + "type": "boolean", + "description": "是否平滑曲线" + }, + "step": { + "type": "boolean", + "description": "是否阶梯图" + }, + "stack": { + "type": "object", + "description": "堆叠配置", + "properties": { + "percentage": { + "type": "boolean", + "description": "是否百分比堆叠" + } + } + }, + "radar": { + "type": "object", + "description": "雷达图配置", + "properties": { + "shape": { + "type": "string", + "description": "雷达图形状", + "enum": [ + "polygon", + "circle" + ] + }, + "area": { + "type": "boolean", + "description": "是否填充区域" + } + } + } + } + }, + "points": { + "type": "object", + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "数据点颜色" + }, + "shape": { + "type": "string", + "description": "数据点形状", + "enum": [ + "circle", + "triangle", + "rect", + "diamond", + "square" + ] + }, + "size": { + "type": "number", + "description": "数据点大小" + }, + "point": { + "type": "array", + "description": "单个数据点配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "数据点索引" + }, + "color": { + "type": "string", + "description": "颜色" + }, + "shape": { + "type": "string", + "description": "形状" + }, + "size": { + "type": "number", + "description": "大小" + } + }, + "required": [ + "index" + ] + } + } + } + }, + "lines": { + "type": "object", + "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "线条颜色" + }, + "width": { + "type": "number", + "description": "线条宽度" + }, + "style": { + "type": "string", + "description": "线条样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "invalidType": { + "type": "string", + "description": "无效值处理方式", + "enum": [ + "break", + "zero", + "link" + ] + } + } + }, + "areas": { + "type": "object", + "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "区域填充颜色" + } + } + }, + "bars": { + "type": "object", + "description": "全系列柱状图、条形图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "柱子颜色" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "width": { + "type": "number", + "description": "柱子宽度" + }, + "gap": { + "type": "number", + "description": "柱子间距比例,0-1之间" + }, + "backgroundColor": { + "type": "string", + "description": "背景颜色" + }, + "bar": { + "type": "array", + "description": "单个柱子配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "柱子索引" + }, + "color": { + "type": "string", + "description": "颜色" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式" + } + }, + "required": [ + "index" + ] + } + } + } + }, + "labels": { + "type": "object", + "description": "数据标签配置", + "properties": { + "position": { + "type": "string", + "description": "标签位置", + "enum": [ + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ] + }, + "series": { + "type": "boolean", + "description": "是否显示系列名" + }, + "category": { + "type": "boolean", + "description": "是否显示类别名" + }, + "value": { + "type": "boolean", + "description": "是否显示值" + }, + "percentage": { + "type": "boolean", + "description": "是否显示百分比" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + } + }, + "series": { + "type": "array", + "description": "单个系列配置数组", + "items": { + "type": "object", + "description": "系列配置", + "properties": { + "index": { + "type": "number", + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)" + }, + "comboType": { + "type": "string", + "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", + "enum": [ + "column", + "line", + "area" + ] + }, + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] + }, + "points": { + "type": "object", + "description": "数据点配置,配置项同 plotArea.points" + }, + "line": { + "type": "object", + "description": "线条配置,配置项同 plotArea.lines" + }, + "area": { + "type": "object", + "description": "区域填充配置,配置项同 plotArea.areas" + }, + "bars": { + "type": "object", + "description": "柱状图配置,配置项同 plotArea.bars" + }, + "labels": { + "type": "object", + "description": "数据标签配置" + }, + "sectors": { + "type": "object", + "description": "扇区配置(饼图)", + "properties": { + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "innerRadius": { + "type": "number", + "description": "内半径比例,0-1之间" + }, + "offsetRadius": { + "type": "number", + "description": "偏移半径比例" + }, + "startAngle": { + "type": "number", + "description": "起始角度,0-359" + }, + "sector": { + "type": "array", + "description": "单个扇区配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "扇区索引" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "offsetRadius": { + "type": "number", + "description": "偏移半径" + }, + "color": { + "type": "string", + "description": "颜色" + } + }, + "required": [ + "index" + ] + } + } + } + } + }, + "required": [ + "index" + ] + } + } + }, + "required": [ + "type" + ] + }, + "axes": { + "type": "array", + "description": "坐标轴配置数组", + "items": { + "type": "object", + "description": "坐标轴配置", + "properties": { + "type": { + "type": "string", + "description": "坐标轴类型", + "enum": [ + "x", + "y", + "angle", + "radius" + ] + }, + "position": { + "type": "string", + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ] + }, + "max": { + "type": "number", + "description": "最大值" + }, + "min": { + "type": "number", + "description": "最小值" + }, + "valueType": { + "type": "string", + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ] + }, + "title": { + "type": "object", + "description": "坐标轴标题配置", + "properties": { + "text": { + "type": "string", + "description": "标题文本" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + }, + "required": [ + "text" + ] + }, + "label": { + "type": "object", + "description": "坐标轴标签配置", + "properties": { + "angle": { + "type": "number", + "description": "旋转角度,可选值:-90, -45, 0, 45, 90" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + } + }, + "axisLine": { + "type": "boolean", + "description": "是否显示轴线" + }, + "gridLine": { + "oneOf": [ + { + "type": "object", + "description": "网格线配置", + "properties": { + "width": { + "type": "number", + "description": "网格线宽度" + }, + "color": { + "type": "string", + "description": "网格线颜色" + } + } + }, + { + "type": "boolean", + "description": "false 表示隐藏网格线" + } + ] + } + }, + "required": [ + "type" + ] + } + } + }, + "required": [ + "plot" + ] + }, + "data": { + "type": "object", + "description": "图表数据配置", + "properties": { + "isStaticData": { + "type": "boolean", + "description": "是否为静态数据" + }, + "includeHiddenOrFilter": { + "type": "boolean", + "description": "是否包含隐藏或过滤的数据" + }, + "direction": { + "type": "string", + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "enum": [ + "row", + "column" + ] + }, + "headerMode": { + "type": "string", + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ] + }, + "refs": { + "type": "array", + "description": "数据源引用范围数组", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域" + } + }, + "required": [ + "value" + ] + } + }, + "dim1": { + "type": "object", + "description": "维度1配置(类别维度)", + "properties": { + "serie": { + "type": "object", + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregate": { + "type": "boolean", + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效" + }, + "nameRef": { + "type": "string", + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" + } + }, + "required": [ + "index" + ] + }, + "field": { + "type": "object", + "description": "字段配置(静态数据时传此参数)", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number", + "string" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } + }, + "dim2": { + "type": "object", + "description": "维度2配置(值维度)", + "properties": { + "series": { + "type": "array", + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregateType": { + "type": "string", + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ] + }, + "nameRef": { + "type": "string", + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" + } + }, + "required": [ + "index" + ] + } + }, + "fields": { + "type": "array", + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } + } + } + } + } + }, + "required": [ + "plotArea", + "data" + ] + } + }, + "required": [ + "position", + "size" + ] + } + }, + "+chart-update": { + "properties": { + "description": "创建/更新的图表属性。", + "type": "object", + "properties": { + "position": { + "type": "object", + "description": "必填。图表在表格中的单元格位置。注意:选择位置时应避免覆盖已有数据的单元格,并确保图表不超出当前表格的行列范围。", + "properties": { + "row": { + "type": "number", + "minimum": 0, + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" + } + }, + "required": [ + "row", + "col" + ] + }, + "offset": { + "type": "object", + "description": "可选。图表在位置基础上的偏移量(像素)。", + "properties": { + "row_offset": { + "type": "number", + "description": "行偏移量(像素)" + }, + "col_offset": { + "type": "number", + "description": "列偏移量(像素)" + } + } + }, + "size": { + "type": "object", + "description": "必填。图表大小(像素)。注意:设定大小时应确保图表不超出当前表格的行列范围,并避免覆盖已有数据的单元格。", + "properties": { + "width": { + "type": "number", + "minimum": 10, + "description": "宽度(像素)" + }, + "height": { + "type": "number", + "minimum": 10, + "description": "高度(像素)" + } + }, + "required": [ + "width", + "height" + ] + }, + "snapshot": { + "type": "object", + "description": "图表快照配置。更新图表时必须传入完整的图表属性定义,不能只传修改的部分。应先通过 get_chart_objects 获取当前图表快照,修改需要变更的字段后,将完整快照传入。", + "properties": { + "title": { + "type": "object", + "description": "图表标题配置", + "properties": { + "text": { + "type": "string", + "description": "标题文本" + }, + "textAlign": { + "type": "string", + "description": "标题对齐方式", + "enum": [ + "left", + "center", + "right" + ] + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + }, + "required": [ + "text" + ] + }, + "subTitle": { + "type": "object", + "description": "图表副标题配置", + "properties": { + "text": { + "type": "string", + "description": "副标题文本" + }, + "textAlign": { + "type": "string", + "description": "副标题对齐方式", + "enum": [ + "left", + "center", + "right" + ] + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + }, + "required": [ + "text" + ] + }, + "style": { + "type": "object", + "description": "图表样式配置", + "properties": { + "background": { + "type": "object", + "description": "背景配置", + "properties": { + "color": { + "type": "string", + "description": "背景颜色,格式为 #RRGGBB" + } + } + }, + "font": { + "type": "object", + "description": "字体配置", + "properties": { + "size": { + "type": "number", + "description": "字体大小" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + } + }, + "border": { + "type": "object", + "description": "边框配置", + "properties": { + "color": { + "type": "string", + "description": "边框颜色,格式为 #RRGGBB" + }, + "width": { + "type": "number", + "description": "边框宽度" + }, + "style": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "radius": { + "type": "number", + "description": "边框圆角" + } + } + }, + "colorTheme": { + "type": "array", + "description": "颜色主题配置。支持两种模式:1. 仅传一个预设主题名称(字符串数组,长度为1);2. 传多个自定义十六进制颜色字符串。预设主题:brandColorSeries@v2、rainbowColorSeries@v2、complementaryColorSeries@v2、converseColorSeries@v2、primaryColorSeries@v2、singleColorSeries-B-@v2、singleColorSeries-W-@v2、singleColorSeries-G-@v2、singleColorSeries-Y-@v2、singleColorSeries-O-@v2、singleColorSeries-R-@v2、singleColorSeries-D-@v2。十六进制格式:#RRGGBB", + "oneOf": [ + { + "minItems": 1, + "maxItems": 1, + "items": { + "type": "string", + "enum": [ + "brandColorSeries@v2", + "rainbowColorSeries@v2", + "complementaryColorSeries@v2", + "converseColorSeries@v2", + "primaryColorSeries@v2", + "singleColorSeries-B-@v2", + "singleColorSeries-W-@v2", + "singleColorSeries-G-@v2", + "singleColorSeries-Y-@v2", + "singleColorSeries-O-@v2", + "singleColorSeries-R-@v2", + "singleColorSeries-D-@v2" + ] + } + }, + { + "minItems": 2, + "items": { + "type": "string", + "description": "颜色字符串,十六进制格式:#RRGGBB" + } + } + ] + }, + "colorGradient": { + "type": "boolean", + "description": "是否启用颜色渐变" + } + } + }, + "legend": { + "oneOf": [ + { + "type": "object", + "description": "图例配置", + "properties": { + "position": { + "type": "string", + "description": "图例位置", + "enum": [ + "top", + "bottom", + "left", + "right" + ] + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色,格式为 #RRGGBB" + } + } + }, + { + "type": "boolean", + "description": "false 表示隐藏图例" + } + ] + }, + "plotArea": { + "type": "object", + "description": "绘图区域配置", + "properties": { + "plot": { + "type": "object", + "description": "绘图配置", + "properties": { + "type": { + "type": "string", + "description": "图表类型", + "enum": [ + "bar", + "column", + "line", + "area", + "combo", + "pie", + "radar", + "scatter" + ] + }, + "comboType": { + "type": "string", + "description": "组合图表默认类型", + "enum": [ + "column", + "line", + "area" + ] + }, + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] + }, + "extra": { + "type": "object", + "description": "额外配置", + "properties": { + "smooth": { + "type": "boolean", + "description": "是否平滑曲线" + }, + "step": { + "type": "boolean", + "description": "是否阶梯图" + }, + "stack": { + "type": "object", + "description": "堆叠配置", + "properties": { + "percentage": { + "type": "boolean", + "description": "是否百分比堆叠" + } + } + }, + "radar": { + "type": "object", + "description": "雷达图配置", + "properties": { + "shape": { + "type": "string", + "description": "雷达图形状", + "enum": [ + "polygon", + "circle" + ] + }, + "area": { + "type": "boolean", + "description": "是否填充区域" + } + } + } + } + }, + "points": { + "type": "object", + "description": "全系列数据点配置,折线图、面积图、雷达图、散点图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "数据点颜色" + }, + "shape": { + "type": "string", + "description": "数据点形状", + "enum": [ + "circle", + "triangle", + "rect", + "diamond", + "square" + ] + }, + "size": { + "type": "number", + "description": "数据点大小" + }, + "point": { + "type": "array", + "description": "单个数据点配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "数据点索引" + }, + "color": { + "type": "string", + "description": "颜色" + }, + "shape": { + "type": "string", + "description": "形状" + }, + "size": { + "type": "number", + "description": "大小" + } + }, + "required": [ + "index" + ] + } + } + } + }, + "lines": { + "type": "object", + "description": "全系列线条配置,折线图、面积图、雷达图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "线条颜色" + }, + "width": { + "type": "number", + "description": "线条宽度" + }, + "style": { + "type": "string", + "description": "线条样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "invalidType": { + "type": "string", + "description": "无效值处理方式", + "enum": [ + "break", + "zero", + "link" + ] + } + } + }, + "areas": { + "type": "object", + "description": "全系列面积填充配置,面积图、雷达图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "区域填充颜色" + } + } + }, + "bars": { + "type": "object", + "description": "全系列柱状图、条形图、组合图生效。", + "properties": { + "color": { + "type": "string", + "description": "柱子颜色" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式", + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "width": { + "type": "number", + "description": "柱子宽度" + }, + "gap": { + "type": "number", + "description": "柱子间距比例,0-1之间" + }, + "backgroundColor": { + "type": "string", + "description": "背景颜色" + }, + "bar": { + "type": "array", + "description": "单个柱子配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "柱子索引" + }, + "color": { + "type": "string", + "description": "颜色" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "borderWidth": { + "type": "number", + "description": "边框宽度" + }, + "borderStyle": { + "type": "string", + "description": "边框样式" + } + }, + "required": [ + "index" + ] + } + } + } + }, + "labels": { + "type": "object", + "description": "数据标签配置", + "properties": { + "position": { + "type": "string", + "description": "标签位置", + "enum": [ + "auto", + "top", + "bottom", + "left", + "right", + "center", + "inside", + "outside" + ] + }, + "series": { + "type": "boolean", + "description": "是否显示系列名" + }, + "category": { + "type": "boolean", + "description": "是否显示类别名" + }, + "value": { + "type": "boolean", + "description": "是否显示值" + }, + "percentage": { + "type": "boolean", + "description": "是否显示百分比" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + } + }, + "series": { + "type": "array", + "description": "单个系列配置数组", + "items": { + "type": "object", + "description": "系列配置", + "properties": { + "index": { + "type": "number", + "description": "数据列索引,与 dim2.series[].index 值一致,从 1 开始(1 通常对应首列标签维度,2+ 对应后续数据维度列)" + }, + "comboType": { + "type": "string", + "description": "组合图下该系列的图表类型,仅在 type 为 combo 时生效", + "enum": [ + "column", + "line", + "area" + ] + }, + "yAxisPosition": { + "type": "string", + "description": "Y轴位置", + "enum": [ + "left", + "right" + ] + }, + "points": { + "type": "object", + "description": "数据点配置,配置项同 plotArea.points" + }, + "line": { + "type": "object", + "description": "线条配置,配置项同 plotArea.lines" + }, + "area": { + "type": "object", + "description": "区域填充配置,配置项同 plotArea.areas" + }, + "bars": { + "type": "object", + "description": "柱状图配置,配置项同 plotArea.bars" + }, + "labels": { + "type": "object", + "description": "数据标签配置" + }, + "sectors": { + "type": "object", + "description": "扇区配置(饼图)", + "properties": { + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "innerRadius": { + "type": "number", + "description": "内半径比例,0-1之间" + }, + "offsetRadius": { + "type": "number", + "description": "偏移半径比例" + }, + "startAngle": { + "type": "number", + "description": "起始角度,0-359" + }, + "sector": { + "type": "array", + "description": "单个扇区配置数组", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "扇区索引" + }, + "borderColor": { + "type": "string", + "description": "边框颜色" + }, + "offsetRadius": { + "type": "number", + "description": "偏移半径" + }, + "color": { + "type": "string", + "description": "颜色" + } + }, + "required": [ + "index" + ] + } + } + } + } + }, + "required": [ + "index" + ] + } + } + }, + "required": [ + "type" + ] + }, + "axes": { + "type": "array", + "description": "坐标轴配置数组", + "items": { + "type": "object", + "description": "坐标轴配置", + "properties": { + "type": { + "type": "string", + "description": "坐标轴类型", + "enum": [ + "x", + "y", + "angle", + "radius" + ] + }, + "position": { + "type": "string", + "description": "坐标轴位置", + "enum": [ + "left", + "right", + "bottom" + ] + }, + "max": { + "type": "number", + "description": "最大值" + }, + "min": { + "type": "number", + "description": "最小值" + }, + "valueType": { + "type": "string", + "description": "是否将横轴数字视为文本,linear 表示将数字视为数值, ordinal 表示将数字视为文本", + "enum": [ + "ordinal", + "linear" + ] + }, + "title": { + "type": "object", + "description": "坐标轴标题配置", + "properties": { + "text": { + "type": "string", + "description": "标题文本" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + }, + "required": [ + "text" + ] + }, + "label": { + "type": "object", + "description": "坐标轴标签配置", + "properties": { + "angle": { + "type": "number", + "description": "旋转角度,可选值:-90, -45, 0, 45, 90" + }, + "fontSize": { + "type": "number", + "description": "字体大小" + }, + "bold": { + "type": "boolean", + "description": "是否加粗" + }, + "italic": { + "type": "boolean", + "description": "是否斜体" + }, + "underline": { + "type": "boolean", + "description": "是否下划线" + }, + "strikethrough": { + "type": "boolean", + "description": "是否删除线" + }, + "color": { + "type": "string", + "description": "字体颜色" + } + } + }, + "axisLine": { + "type": "boolean", + "description": "是否显示轴线" + }, + "gridLine": { + "oneOf": [ + { + "type": "object", + "description": "网格线配置", + "properties": { + "width": { + "type": "number", + "description": "网格线宽度" + }, + "color": { + "type": "string", + "description": "网格线颜色" + } + } + }, + { + "type": "boolean", + "description": "false 表示隐藏网格线" + } + ] + } + }, + "required": [ + "type" + ] + } + } + }, + "required": [ + "plot" + ] + }, + "data": { + "type": "object", + "description": "图表数据配置", + "properties": { + "isStaticData": { + "type": "boolean", + "description": "是否为静态数据" + }, + "includeHiddenOrFilter": { + "type": "boolean", + "description": "是否包含隐藏或过滤的数据" + }, + "direction": { + "type": "string", + "description": "数据方向,可选值:row(行方向)或column(列方向),常用值为column", + "enum": [ + "row", + "column" + ] + }, + "headerMode": { + "type": "string", + "description": "表头模式。inline 表示 refs 首行/首列就是表头;detached 表示 refs 仅覆盖实际数据范围,维度名和系列名需通过 nameRef 显式指定。", + "enum": [ + "inline", + "detached" + ] + }, + "refs": { + "type": "array", + "description": "数据源引用范围数组", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "图表数据源范围,格式为 'SheetName!A1:D100' 的连续区域" + } + }, + "required": [ + "value" + ] + } + }, + "dim1": { + "type": "object", + "description": "维度1配置(类别维度)", + "properties": { + "serie": { + "type": "object", + "description": "系列配置(非静态数据时传此参数)", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregate": { + "type": "boolean", + "description": "是否汇总相同的类别,默认为true,true 情况下 aggregateType 才生效" + }, + "nameRef": { + "type": "string", + "description": "可选。维度名称的单元格引用(A1 表示法,如 'Sheet1!A1')。当维度名称不直接来自 refs 首行/首列时,可通过此字段把维度名显式指向目标表头单元格,图表会以该单元格当前值作为类别维度名展示。" + } + }, + "required": [ + "index" + ] + }, + "field": { + "type": "object", + "description": "字段配置(静态数据时传此参数)", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number", + "string" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } + }, + "dim2": { + "type": "object", + "description": "维度2配置(值维度)", + "properties": { + "series": { + "type": "array", + "description": "系列配置数组(非静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number", + "description": "系列索引,数据源从左往右,从 1 开始计数对应数据源的列索引;如果数据方向direction为 'row',则为从上往下,从 1 开始计数对应数据源的行索引" + }, + "aggregateType": { + "type": "string", + "description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效", + "enum": [ + "sum", + "average", + "count", + "min", + "max", + "median" + ] + }, + "nameRef": { + "type": "string", + "description": "可选。系列名称的单元格引用(A1 表示法,如 'Sheet1!C1')。当系列名称不直接来自 refs 首行/首列时,可通过此字段把系列名显式指向目标表头单元格,图表会以该单元格当前值作为系列名(图例/tooltip)展示。" + } + }, + "required": [ + "index" + ] + } + }, + "fields": { + "type": "array", + "description": "字段配置数组(静态数据时传此参数)", + "items": { + "type": "object", + "properties": { + "valueType": { + "type": "string", + "description": "值类型", + "enum": [ + "number" + ] + }, + "name": { + "type": "string", + "description": "字段名称" + }, + "text": { + "type": "string", + "description": "字段文本数据" + } + }, + "required": [ + "text" + ] + } + } + } + } + } + } + }, + "required": [ + "plotArea", + "data" + ] + } + }, + "required": [ + "position", + "size" + ] + } + }, + "+cond-format-create": { + "properties": { + "description": "创建/更新的条件格式属性。", + "type": "object", + "properties": { + "rule_type": { + "type": "string", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ], + "description": "条件格式规则类型。" + }, + "ranges": { + "type": "array", + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + } + }, + "style": { + "type": "object", + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "type": "string", + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。" + }, + "fore_color": { + "type": "string", + "description": "前景色/字体颜色。" + }, + "text_decoration": { + "type": "string", + "enum": [ + "none", + "underline", + "strikethrough", + "underline_strikethrough" + ], + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。" + }, + "font": { + "type": "string", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" + } + } + }, + "attrs": { + "type": "array", + "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", + "items": { + "oneOf": [ + { + "type": "object", + "description": "数值比较类规则参数。", + "properties": { + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "description": "比较运算符。" + }, + "value": { + "type": "string", + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。" + } + }, + "required": [ + "compare_type", + "value" + ] + }, + { + "type": "object", + "description": "文本包含类规则参数。", + "properties": { + "text": { + "type": "string", + "description": "用于匹配的文本内容。" + }, + "compare_type": { + "type": "string", + "enum": [ + "beginsWith", + "endsWith", + "containsText", + "notContains", + "is" + ], + "description": "文本匹配方式。" + } + }, + "required": [ + "compare_type", + "text" + ] + }, + { + "type": "object", + "description": "时间段类规则参数。", + "properties": { + "operator": { + "type": "string", + "enum": [ + "before", + "is", + "after" + ], + "description": "与指定时间段的比较关系。" + }, + "time_period": { + "type": "string", + "enum": [ + "today", + "yesterday", + "tomorrow", + "last7Days", + "thisMonth", + "lastMonth", + "nextMonth", + "thisWeek", + "lastWeek", + "nextWeek" + ], + "description": "时间段类型。" + } + }, + "required": [ + "operator", + "time_period" + ] + }, + { + "type": "object", + "description": "数据条规则参数。", + "properties": { + "gradient": { + "type": "boolean", + "description": "是否使用渐变色数据条。" + }, + "value": { + "type": "number", + "description": "阈值或比例值,含义由 value_type 决定。" + }, + "color": { + "type": "string", + "description": "主颜色,例如 \"#63BE7B\"。" + }, + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格中的原始值,仅显示数据条。" + }, + "value_type": { + "type": "string", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "description": "阈值类型。" + } + }, + "required": [ + "color", + "value_type" + ] + }, + { + "type": "object", + "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", + "properties": { + "value_type": { + "type": "string", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "description": "阈值类型。" + }, + "value": { + "type": "number", + "description": "阈值数值,例如百分位或具体数值。" + }, + "color": { + "type": "string", + "description": "该分段对应的颜色。" + } + }, + "required": [ + "value_type", + "color" + ] + }, + { + "type": "object", + "description": "前 N/后 N 规则参数。", + "properties": { + "is_bottom": { + "type": "boolean", + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。" + }, + "value_type": { + "type": "string", + "enum": [ + "percent", + "sort" + ], + "description": "排名方式:percent 表示百分比,sort 表示按条目数。" + }, + "value": { + "type": "number", + "description": "N 或百分比数值。" + } + }, + "required": [ + "is_bottom", + "value_type" + ] + }, + { + "type": "object", + "description": "平均值规则参数。", + "properties": { + "operator": { + "type": "string", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "description": "与平均值的比较关系。" + } + }, + "required": [ + "operator" + ] + }, + { + "type": "object", + "description": "自定义公式规则参数。", + "properties": { + "formula": { + "type": "array", + "items": { + "type": "string" + }, + "description": "条件公式列表,例如 [\"=A1>0\"]。" + } + }, + "required": [ + "formula" + ] + }, + { + "type": "object", + "description": "图标集规则参数。", + "properties": { + "icon_type": { + "type": "string", + "enum": [ + "3Arrows", + "3ArrowsGray", + "3Triangles", + "4ArrowsGray", + "4Arrows", + "5ArrowsGray", + "5Arrows", + "3Circles", + "3MultiGraphics", + "4Circles", + "5Circles", + "2Status", + "3Status", + "2CommentStatus", + "3Flags", + "3Stars", + "3HeartShaped", + "3Mood", + "5CirclesRatio" + ], + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。" + }, + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格原始值,仅显示图标。" + }, + "value_type": { + "type": "string", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "description": "阈值类型。" + }, + "operator": { + "type": "string", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "description": "与阈值的比较关系。" + }, + "value": { + "type": "number", + "description": "用于比较的数值,含义由 value_type 决定。" + }, + "reverse_icons": { + "type": "boolean", + "description": "是否反转图标顺序。" + } + }, + "required": [ + "icon_type", + "value_type", + "operator" + ] + } + ] + } + }, + "has_ref": { + "type": "boolean", + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。" + } + }, + "required": [ + "rule_type", + "ranges", + "style" + ] + } + }, + "+cond-format-update": { + "properties": { + "description": "创建/更新的条件格式属性。", + "type": "object", + "properties": { + "rule_type": { + "type": "string", + "enum": [ + "duplicateValues", + "uniqueValues", + "cellIs", + "containsText", + "timePeriod", + "containsBlanks", + "notContainsBlanks", + "dataBar", + "colorScale", + "rank", + "aboveAverage", + "expression", + "iconSet" + ], + "description": "条件格式规则类型。" + }, + "ranges": { + "type": "array", + "description": "应用条件格式的 A1 范围列表。每个元素支持:矩形范围 \"A1:A10\"、整行 \"3:6\"、整列 \"C:C\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。例如 [\"A1:A10\",\"C:C\"]。", + "items": { + "type": "string" + } + }, + "style": { + "type": "object", + "description": "命中规则时应用的单元格样式。", + "properties": { + "back_color": { + "type": "string", + "description": "背景色,CSS 十六进制表示,例如 \"#FF0000\"。" + }, + "fore_color": { + "type": "string", + "description": "前景色/字体颜色。" + }, + "text_decoration": { + "type": "string", + "enum": [ + "none", + "underline", + "strikethrough", + "underline_strikethrough" + ], + "description": "文字装饰:none=无,underline=下划线,strikethrough=删除线,underline_strikethrough=下划线+删除线。" + }, + "font": { + "type": "string", + "enum": [ + "bold", + "italic", + "bold italic" + ], + "description": "字体样式:\"bold\"=加粗,\"italic\"=斜体,\"bold italic\"=加粗+斜体。" + } + } + }, + "attrs": { + "type": "array", + "description": "规则参数列表。不同 rule_type 下取值结构不同。当 rule_type 为 dataBar 时,attrs 必须包含两个对象,分别定义正值和负值的数据条颜色。", + "items": { + "oneOf": [ + { + "type": "object", + "description": "数值比较类规则参数。", + "properties": { + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "description": "比较运算符。" + }, + "value": { + "type": "string", + "description": "用于比较的值,例如 \"100\"、\"hello\"。between/notBetween 时用逗号分隔两个值。" + } + }, + "required": [ + "compare_type", + "value" + ] + }, + { + "type": "object", + "description": "文本包含类规则参数。", + "properties": { + "text": { + "type": "string", + "description": "用于匹配的文本内容。" + }, + "compare_type": { + "type": "string", + "enum": [ + "beginsWith", + "endsWith", + "containsText", + "notContains", + "is" + ], + "description": "文本匹配方式。" + } + }, + "required": [ + "compare_type", + "text" + ] + }, + { + "type": "object", + "description": "时间段类规则参数。", + "properties": { + "operator": { + "type": "string", + "enum": [ + "before", + "is", + "after" + ], + "description": "与指定时间段的比较关系。" + }, + "time_period": { + "type": "string", + "enum": [ + "today", + "yesterday", + "tomorrow", + "last7Days", + "thisMonth", + "lastMonth", + "nextMonth", + "thisWeek", + "lastWeek", + "nextWeek" + ], + "description": "时间段类型。" + } + }, + "required": [ + "operator", + "time_period" + ] + }, + { + "type": "object", + "description": "数据条规则参数。", + "properties": { + "gradient": { + "type": "boolean", + "description": "是否使用渐变色数据条。" + }, + "value": { + "type": "number", + "description": "阈值或比例值,含义由 value_type 决定。" + }, + "color": { + "type": "string", + "description": "主颜色,例如 \"#63BE7B\"。" + }, + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格中的原始值,仅显示数据条。" + }, + "value_type": { + "type": "string", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "description": "阈值类型。" + } + }, + "required": [ + "color", + "value_type" + ] + }, + { + "type": "object", + "description": "色阶规则中的单个分段。ColorScaleAttrs 由 2 或 3 个该对象组成,分别表示最小值/中间值/最大值。", + "properties": { + "value_type": { + "type": "string", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "description": "阈值类型。" + }, + "value": { + "type": "number", + "description": "阈值数值,例如百分位或具体数值。" + }, + "color": { + "type": "string", + "description": "该分段对应的颜色。" + } + }, + "required": [ + "value_type", + "color" + ] + }, + { + "type": "object", + "description": "前 N/后 N 规则参数。", + "properties": { + "is_bottom": { + "type": "boolean", + "description": "是否选取最后 N 个。true 表示最后 N 个,false 表示前 N 个。" + }, + "value_type": { + "type": "string", + "enum": [ + "percent", + "sort" + ], + "description": "排名方式:percent 表示百分比,sort 表示按条目数。" + }, + "value": { + "type": "number", + "description": "N 或百分比数值。" + } + }, + "required": [ + "is_bottom", + "value_type" + ] + }, + { + "type": "object", + "description": "平均值规则参数。", + "properties": { + "operator": { + "type": "string", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "description": "与平均值的比较关系。" + } + }, + "required": [ + "operator" + ] + }, + { + "type": "object", + "description": "自定义公式规则参数。", + "properties": { + "formula": { + "type": "array", + "items": { + "type": "string" + }, + "description": "条件公式列表,例如 [\"=A1>0\"]。" + } + }, + "required": [ + "formula" + ] + }, + { + "type": "object", + "description": "图标集规则参数。", + "properties": { + "icon_type": { + "type": "string", + "enum": [ + "3Arrows", + "3ArrowsGray", + "3Triangles", + "4ArrowsGray", + "4Arrows", + "5ArrowsGray", + "5Arrows", + "3Circles", + "3MultiGraphics", + "4Circles", + "5Circles", + "2Status", + "3Status", + "2CommentStatus", + "3Flags", + "3Stars", + "3HeartShaped", + "3Mood", + "5CirclesRatio" + ], + "description": "图标集类型。其中 3Circles/4Circles/5Circles 为不同颜色的实心圆(如红、黄、绿),用于表示状态等级;5CirclesRatio 为不同填充比例的圆(从空心◯到半圆◑到满圆●),用于表示完成度、进度、比例。当涉及半圆、满圆、空心圆、填充比例、进度等场景时,应选择 5CirclesRatio。" + }, + "hide_value": { + "type": "boolean", + "description": "是否隐藏单元格原始值,仅显示图标。" + }, + "value_type": { + "type": "string", + "enum": [ + "minValue", + "maxValue", + "num", + "percentile", + "percent", + "formula", + "auto" + ], + "description": "阈值类型。" + }, + "operator": { + "type": "string", + "enum": [ + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual" + ], + "description": "与阈值的比较关系。" + }, + "value": { + "type": "number", + "description": "用于比较的数值,含义由 value_type 决定。" + }, + "reverse_icons": { + "type": "boolean", + "description": "是否反转图标顺序。" + } + }, + "required": [ + "icon_type", + "value_type", + "operator" + ] + } + ] + } + }, + "has_ref": { + "type": "boolean", + "description": "可选。是否存在引用,用于标记规则是否依赖其他对象。" + } + }, + "required": [ + "rule_type", + "ranges", + "style" + ] + } + }, + "+dropdown-set": { + "options": { + "description": "列表选项", + "type": "array", + "items": { + "type": "string" + } + } + }, + "+dropdown-update": { + "options": { + "description": "列表选项", + "type": "array", + "items": { + "type": "string" + } + } + }, + "+filter-create": { + "properties": { + "description": "创建/更新的筛选器属性。", + "type": "object", + "properties": { + "range": { + "type": "string", + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" + }, + "rules": { + "type": "array", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 为整组覆盖(PUT 语义):传入的 rules 即更新后的完整规则集,会先清空所有现有列的旧规则再应用,未列出的列其旧规则会被清除(不与旧规则合并);传空数组 [] 即清空所有列的规则。", + "items": { + "type": "object", + "description": "单列筛选规则。", + "properties": { + "column_index": { + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" + }, + "conditions": { + "type": "array", + "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", + "items": { + "oneOf": [ + { + "type": "object", + "description": "文本条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "beginsWith", + "doesNotBeginWith", + "endsWith", + "doesNotEndWith", + "contains", + "doesNotContain", + "equals", + "notEquals" + ], + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" + }, + "values": { + "type": "array", + "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "数值条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" + }, + "values": { + "type": "array", + "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "颜色条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "color" + ], + "description": "条件类型固定为 \"color\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "backgroundColor", + "foregroundColor" + ], + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" + }, + "value": { + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "多值条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual" + ], + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } + }, + "date_groups": { + "type": "array", + "description": "可选。年月日等聚合筛选信息。", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number" + }, + "month": { + "type": "number" + }, + "day": { + "type": "number" + }, + "hour": { + "type": "number" + }, + "minute": { + "type": "number" + }, + "second": { + "type": "number" + }, + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] + } + } + } + } + }, + "required": [ + "type", + "compare_type" + ] + } + ] + } + }, + "filtered_rows": { + "type": "array", + "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", + "items": { + "type": "number" + } + } + }, + "required": [ + "column_index", + "conditions" + ] + } + }, + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } + } + }, + "required": [ + "range", + "rules" + ] + } + }, + "+filter-update": { + "properties": { + "description": "创建/更新的筛选器属性。", + "type": "object", + "properties": { + "range": { + "type": "string", + "description": "筛选对象作用的单元格范围(A1 表示法)。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" + }, + "rules": { + "type": "array", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。update 为整组覆盖(PUT 语义):传入的 rules 即更新后的完整规则集,会先清空所有现有列的旧规则再应用,未列出的列其旧规则会被清除(不与旧规则合并);传空数组 [] 即清空所有列的规则。", + "items": { + "type": "object", + "description": "单列筛选规则。", + "properties": { + "column_index": { + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" + }, + "conditions": { + "type": "array", + "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", + "items": { + "oneOf": [ + { + "type": "object", + "description": "文本条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "beginsWith", + "doesNotBeginWith", + "endsWith", + "doesNotEndWith", + "contains", + "doesNotContain", + "equals", + "notEquals" + ], + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" + }, + "values": { + "type": "array", + "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "数值条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" + }, + "values": { + "type": "array", + "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "颜色条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "color" + ], + "description": "条件类型固定为 \"color\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "backgroundColor", + "foregroundColor" + ], + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" + }, + "value": { + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "多值条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual" + ], + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } + }, + "date_groups": { + "type": "array", + "description": "可选。年月日等聚合筛选信息。", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number" + }, + "month": { + "type": "number" + }, + "day": { + "type": "number" + }, + "hour": { + "type": "number" + }, + "minute": { + "type": "number" + }, + "second": { + "type": "number" + }, + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] + } + } + } + } + }, + "required": [ + "type", + "compare_type" + ] + } + ] + } + }, + "filtered_rows": { + "type": "array", + "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", + "items": { + "type": "number" + } + } + }, + "required": [ + "column_index", + "conditions" + ] + } + }, + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } + } + }, + "required": [ + "range", + "rules" + ] + } + }, + "+filter-view-create": { + "properties": { + "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", + "type": "object", + "properties": { + "view_name": { + "type": "string", + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。" + }, + "range": { + "type": "string", + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" + }, + "rules": { + "type": "array", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。update 同样为整组覆盖(PUT 语义):传入的 rules 整组替换该视图所有列的规则,未列出的列其旧规则会被清除(不与旧规则合并),传空数组 [] 即清空全部。", + "items": { + "type": "object", + "description": "单列筛选规则。", + "properties": { + "column_index": { + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" + }, + "conditions": { + "type": "array", + "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", + "items": { + "oneOf": [ + { + "type": "object", + "description": "文本条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "beginsWith", + "doesNotBeginWith", + "endsWith", + "doesNotEndWith", + "contains", + "doesNotContain", + "equals", + "notEquals" + ], + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" + }, + "values": { + "type": "array", + "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "数值条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" + }, + "values": { + "type": "array", + "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "颜色条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "color" + ], + "description": "条件类型固定为 \"color\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "backgroundColor", + "foregroundColor" + ], + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" + }, + "value": { + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "多值条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual" + ], + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } + }, + "date_groups": { + "type": "array", + "description": "可选。年月日等聚合筛选信息。", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number" + }, + "month": { + "type": "number" + }, + "day": { + "type": "number" + }, + "hour": { + "type": "number" + }, + "minute": { + "type": "number" + }, + "second": { + "type": "number" + }, + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] + } + } + } + } + }, + "required": [ + "type", + "compare_type" + ] + } + ] + } + }, + "filtered_rows": { + "type": "array", + "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", + "items": { + "type": "number" + } + } + }, + "required": [ + "column_index", + "conditions" + ] + } + }, + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } + } + } + } + }, + "+filter-view-update": { + "properties": { + "description": "create / update 的视图属性。create 必须传 range;update 至少传 view_name / range / rules 之一。delete 禁止传该字段。", + "type": "object", + "properties": { + "view_name": { + "type": "string", + "description": "可选。视图名称。create 不传时系统自动分配;create / update 传入时若与现有视图重名,会自动以后缀避让,不会抛错。" + }, + "range": { + "type": "string", + "description": "视图作用的单元格范围(A1 表示法)。create 必填;update 可选——未提供时沿用视图当前 range。支持:矩形范围 \"A1:D100\"、整行 \"3:6\"、整列 \"C:E\"、某一格到列尾 \"D3:D\"、某一格到行尾 \"D3:3\"。" + }, + "rules": { + "type": "array", + "description": "列级筛选规则列表,每一项对应一个具体列的筛选条件。结构与 manage_filter_object.properties.rules 完全一致。update 同样为整组覆盖(PUT 语义):传入的 rules 整组替换该视图所有列的规则,未列出的列其旧规则会被清除(不与旧规则合并),传空数组 [] 即清空全部。", + "items": { + "type": "object", + "description": "单列筛选规则。", + "properties": { + "column_index": { + "type": "string", + "description": "作用的列索引,例如 \"A\"、\"B\"。" + }, + "conditions": { + "type": "array", + "description": "该列上的条件列表。**目前每列仅支持 1 个 condition**(传入多个会被拒绝,请用 multiValue 或单条件表达),数组形式为未来扩展多条件组合预留。", + "items": { + "oneOf": [ + { + "type": "object", + "description": "文本条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ], + "description": "条件类型固定为 \"text\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "beginsWith", + "doesNotBeginWith", + "endsWith", + "doesNotEndWith", + "contains", + "doesNotContain", + "equals", + "notEquals" + ], + "description": "文本比较方式:beginsWith=以指定文本开头,doesNotBeginWith=不以指定文本开头,endsWith=以指定文本结尾,doesNotEndWith=不以指定文本结尾,contains=包含,doesNotContain=不包含,equals=等于,notEquals=不等于。" + }, + "values": { + "type": "array", + "description": "可选。用于比较的文本值列表,只需要提供一个值,例如 [\"华东\"]。", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "数值条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "number" + ], + "description": "条件类型固定为 \"number\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual", + "greaterThan", + "greaterThanOrEqual", + "lessThan", + "lessThanOrEqual", + "between", + "notBetween" + ], + "description": "数值比较方式,例如 greaterThan=大于、between=介于两个值之间。" + }, + "values": { + "type": "array", + "description": "可选。用于比较的数值参数列表。大多数比较运算需要 1 个值(如 [5000]),between/notBetween 需要 2 个值(如 [1000, 5000])。", + "items": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "颜色条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "color" + ], + "description": "条件类型固定为 \"color\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "backgroundColor", + "foregroundColor" + ], + "description": "颜色比较类型:backgroundColor=按单元格背景色,foregroundColor=按字体颜色。" + }, + "value": { + "type": "string", + "description": "可选。用于匹配的颜色值,使用十六进制颜色字符串,例如 \"#FF0000\"。" + } + }, + "required": [ + "type", + "compare_type" + ] + }, + { + "type": "object", + "description": "多值条件筛选。", + "properties": { + "type": { + "type": "string", + "enum": [ + "multiValue" + ], + "description": "条件类型固定为 \"multiValue\"。" + }, + "compare_type": { + "type": "string", + "enum": [ + "equal", + "notEqual" + ], + "description": "多值比较方式:equal=仅保留 values 中的值,notEqual=排除 values 中的值。" + }, + "values": { + "type": "array", + "description": "可选。参与筛选的枚举值列表,例如 [\"华东\", \"华南\"]。", + "items": { + "type": "string" + } + }, + "date_groups": { + "type": "array", + "description": "可选。年月日等聚合筛选信息。", + "items": { + "type": "object", + "properties": { + "year": { + "type": "number" + }, + "month": { + "type": "number" + }, + "day": { + "type": "number" + }, + "hour": { + "type": "number" + }, + "minute": { + "type": "number" + }, + "second": { + "type": "number" + }, + "date_time_grouping": { + "type": "string", + "enum": [ + "year", + "month", + "day", + "hour", + "minute", + "second" + ] + } + } + } + } + }, + "required": [ + "type", + "compare_type" + ] + } + ] + } + }, + "filtered_rows": { + "type": "array", + "description": "可选。由该列条件直接导致被隐藏的行索引列表(0-based),用于调试或回显。", + "items": { + "type": "number" + } + } + }, + "required": [ + "column_index", + "conditions" + ] + } + }, + "filtered_columns": { + "type": "array", + "description": "可选。拥有激活筛选条件的列索引列表,如 [\"A\", \"B\"],通常等价于 rules 中出现的列索引集合。", + "items": { + "type": "string" + } + } + } + } + }, + "+pivot-create": { + "properties": { + "description": "创建/更新的透视表属性。", + "type": "object", + "properties": { + "range": { + "type": "string", + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。" + }, + "source": { + "type": "string", + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。" + }, + "rows": { + "description": "纵向分组字段(行字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。" + }, + "sort": { + "type": "object", + "description": "排序配置", + "properties": { + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "排序方向" + }, + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" + }, + "value_field": { + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" + } + }, + "required": [ + "order" + ] + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] + }, + "group": { + "type": "object", + "description": "分组配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, + "date_group_by": { + "type": "string", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" + }, + "end": { + "type": "number", + "description": "数值分组结束值" + }, + "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "field" + ] + } + }, + "columns": { + "description": "横向分组字段(列字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "sort": { + "type": "object", + "description": "排序配置", + "properties": { + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "排序方向" + }, + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" + }, + "value_field": { + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" + } + }, + "required": [ + "order" + ] + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] + }, + "group": { + "type": "object", + "description": "分组配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, + "date_group_by": { + "type": "string", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" + }, + "end": { + "type": "number", + "description": "数值分组结束值" + }, + "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "field" + ] + } + }, + "filters": { + "description": "筛选区域字段(页字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] + }, + "group": { + "type": "object", + "description": "分组配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, + "date_group_by": { + "type": "string", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" + }, + "end": { + "type": "number", + "description": "数值分组结束值" + }, + "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "field" + ] + } + }, + "values": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "要汇总的源数据字段名" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "summarize_by": { + "default": "sum", + "description": "汇总函数", + "type": "string", + "enum": [ + "sum", + "count", + "average", + "max", + "min", + "product", + "countNums", + "stdDev", + "stdDevp", + "var", + "varp", + "distinct", + "median" + ] + }, + "show_data_as": { + "type": "string", + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ] + }, + "base_field": { + "type": "string", + "description": "show_data_as 需要基准字段时的字段名" + } + }, + "required": [ + "field" + ] + }, + "description": "要汇总的字段(至少需要 1 个)" + }, + "auto_fit_col": { + "type": "boolean", + "description": "是否自动调整列宽以适应内容" + }, + "show_row_grand_total": { + "type": "boolean", + "description": "是否显示行总计(默认 true)" + }, + "show_col_grand_total": { + "type": "boolean", + "description": "是否显示列总计(默认 true)" + }, + "show_subtotals": { + "type": "boolean", + "description": "是否显示分类小计(默认 true,应用于所有字段)" + }, + "repeat_row_labels": { + "type": "boolean", + "description": "是否显示重复项标签" + }, + "calculated_fields": { + "type": "array", + "description": "计算字段列表", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "计算字段的显示名称" + }, + "formula": { + "type": "string", + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"" + }, + "summarize_by": { + "type": "string", + "default": "sum", + "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", + "enum": [ + "sum", + "custom" + ] + } + }, + "required": [ + "name", + "formula" + ] + } + }, + "collapse": { + "type": "object", + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "+pivot-update": { + "properties": { + "description": "创建/更新的透视表属性。", + "type": "object", + "properties": { + "range": { + "type": "string", + "description": "放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效)。可省略——省略后透视表放在新建子表的 A1 位置。" + }, + "source": { + "type": "string", + "description": "源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100')。create 时必填;update 时提供则切换数据源。" + }, + "rows": { + "description": "纵向分组字段(行字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用;否则保持源字段原名,透视表产物已足够清晰。" + }, + "sort": { + "type": "object", + "description": "排序配置", + "properties": { + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "排序方向" + }, + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" + }, + "value_field": { + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" + } + }, + "required": [ + "order" + ] + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] + }, + "group": { + "type": "object", + "description": "分组配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, + "date_group_by": { + "type": "string", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" + }, + "end": { + "type": "number", + "description": "数值分组结束值" + }, + "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "field" + ] + } + }, + "columns": { + "description": "横向分组字段(列字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "sort": { + "type": "object", + "description": "排序配置", + "properties": { + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "description": "排序方向" + }, + "by": { + "type": "string", + "enum": [ + "label", + "value" + ], + "description": "'label' = 按字段标签排序(默认),'value' = 按某个值字段的汇总结果排序" + }, + "value_field": { + "type": "string", + "description": "by='value' 时必填,指定按哪个值字段排序" + } + }, + "required": [ + "order" + ] + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] + }, + "group": { + "type": "object", + "description": "分组配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, + "date_group_by": { + "type": "string", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" + }, + "end": { + "type": "number", + "description": "数值分组结束值" + }, + "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "field" + ] + } + }, + "filters": { + "description": "筛选区域字段(页字段)", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "源数据中的字段名(OOXML 字段引用)" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "filter": { + "type": "object", + "description": "快速筛选:只显示指定的项目", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "要显示的项目列表(其余项目被隐藏)" + } + }, + "required": [ + "items" + ] + }, + "condition_filter": { + "type": "object", + "description": "条件筛选:按文本/数值/日期条件筛选", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date" + ], + "description": "条件类型" + }, + "operator": { + "type": "string", + "description": "text: equals|notEquals|contains|notContains|beginsWith|notBeginsWith|endsWith|notEndsWith|empty|notEmpty; number: equals|notEquals|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual|between|notBetween; date: equals|notEquals|earlierThan|earlierThanOrEqual|laterThan|laterThanOrEqual|between|notBetween|today|yesterday|tomorrow|thisWeek|lastWeek|nextWeek|thisMonth|lastMonth|nextMonth|thisQuarter|lastQuarter|nextQuarter|thisYear|lastYear|nextYear|yearToDate|quarter|month" + }, + "value": { + "description": "比较值" + }, + "value2": { + "description": "'between'/'notBetween' 的第二个边界值" + } + }, + "required": [ + "type", + "operator" + ] + }, + "group": { + "type": "object", + "description": "分组配置", + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "number", + "element" + ], + "description": "分组类型" + }, + "date_group_by": { + "type": "string", + "enum": [ + "year", + "yearMonth", + "yearQuarter", + "yearMonthDate", + "quarter", + "month", + "monthDate", + "date", + "hour", + "hourMinute", + "minute" + ], + "description": "日期分组粒度(type='date' 时必填)" + }, + "interval": { + "type": "number", + "description": "数值分组间隔(type='number' 时必填)" + }, + "start": { + "type": "number", + "description": "数值分组起始值" + }, + "end": { + "type": "number", + "description": "数值分组结束值" + }, + "groups": { + "type": "object", + "description": "元素分组:组名 -> 项目列表(type='element' 时必填)", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "field" + ] + } + }, + "values": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "要汇总的源数据字段名" + }, + "display_name": { + "type": "string", + "description": "字段在透视表中的显示名(典型场景:把 count(销售额) 的值列头显示为 'GMV')。不传则使用源字段原名。仅在用户明确要求重命名时使用。" + }, + "summarize_by": { + "default": "sum", + "description": "汇总函数", + "type": "string", + "enum": [ + "sum", + "count", + "average", + "max", + "min", + "product", + "countNums", + "stdDev", + "stdDevp", + "var", + "varp", + "distinct", + "median" + ] + }, + "show_data_as": { + "type": "string", + "description": "值显示方式(默认 'normal')", + "enum": [ + "normal", + "percentOfTotal", + "percentOfCol", + "percentOfRow", + "percentOfParentRow", + "percentOfParentCol", + "index" + ] + }, + "base_field": { + "type": "string", + "description": "show_data_as 需要基准字段时的字段名" + } + }, + "required": [ + "field" + ] + }, + "description": "要汇总的字段(至少需要 1 个)" + }, + "auto_fit_col": { + "type": "boolean", + "description": "是否自动调整列宽以适应内容" + }, + "show_row_grand_total": { + "type": "boolean", + "description": "是否显示行总计(默认 true)" + }, + "show_col_grand_total": { + "type": "boolean", + "description": "是否显示列总计(默认 true)" + }, + "show_subtotals": { + "type": "boolean", + "description": "是否显示分类小计(默认 true,应用于所有字段)" + }, + "repeat_row_labels": { + "type": "boolean", + "description": "是否显示重复项标签" + }, + "calculated_fields": { + "type": "array", + "description": "计算字段列表", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "计算字段的显示名称" + }, + "formula": { + "type": "string", + "description": "公式表达式,引用其他字段名,例如 \"'Sales' + 'Tax'\"" + }, + "summarize_by": { + "type": "string", + "default": "sum", + "description": "计算字段汇总方式(默认 'sum',仅支持 sum/custom 两种):'sum' 表示先对 formula 中引用的各源字段分别求和后再代入公式(formula 是逐字段求和后的标量表达式,例如 \"'Sales'+'Tax'\" 等价于 SUM(Sales)+SUM(Tax));'custom' 表示数组直接传给 formula,要求 formula 自带聚合函数(例如 \"SUM('Sales')+SUM('Tax')\" 或 \"AVERAGE('Sales')\")。需要 count/average/max/min 等其他聚合方式时,请改用 values[] 字段对源字段直接聚合,calculated_fields 不适用。", + "enum": [ + "sum", + "custom" + ] + } + }, + "required": [ + "name", + "formula" + ] + } + }, + "collapse": { + "type": "object", + "description": "行字段展开/折叠状态:字段名 -> 要折叠的项目列表", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "+range-sort": { + "sort-keys": { + "type": "array", + "items": { + "type": "object", + "properties": { + "column": { + "type": "string", + "description": "排序依据的列字母(如 \"C\"、\"D\"),必须在 range 范围内" + }, + "ascending": { + "type": "boolean", + "description": "是否升序排序" + } + }, + "required": [ + "column", + "ascending" + ] + }, + "description": "排序条件列表(仅 sort 操作)。支持多级排序,靠前的条件优先级更高。" + } + }, + "+sparkline-create": { + "properties": { + "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", + "type": "object", + "properties": { + "config": { + "type": "object", + "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", + "properties": { + "theme_type": { + "type": "string", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "description": "主题类型:pro、light、soft、brand、fresh。" + }, + "non_num_show_as": { + "type": "string", + "enum": [ + "zero", + "gap", + "average" + ], + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + }, + "empty_show_as": { + "type": "string", + "enum": [ + "zero", + "gap", + "average" + ], + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + }, + "contain_hidden_cells": { + "type": "boolean", + "description": "隐藏的单元格数据是否参与绘制。" + }, + "series_color": { + "type": "string", + "description": "主系列颜色,例如 \"#4472C4\"。" + }, + "points": { + "type": "object", + "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", + "properties": { + "last_point": { + "type": "object", + "description": "尾点配置,最后一个数据点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "negative_point": { + "type": "object", + "description": "负点配置,负数点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "markers_point": { + "type": "object", + "description": "标记点配置,所有标记点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "first_point": { + "type": "object", + "description": "首点配置,第一个数据点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "high_point": { + "type": "object", + "description": "高点配置,最高点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "low_point": { + "type": "object", + "description": "低点配置,最低点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + } + } + }, + "line_width": { + "type": "number", + "enum": [ + 1, + 2, + 3, + 4 + ], + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。" + }, + "type": { + "type": "string", + "enum": [ + "line", + "column", + "win_loss" + ], + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。" + }, + "axis": { + "type": "object", + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "type": "string", + "description": "坐标轴颜色。" + }, + "reverse": { + "type": "boolean", + "description": "是否翻转坐标轴方向。" + }, + "visible": { + "type": "boolean", + "description": "是否显示坐标轴。" + } + } + }, + "show_gradient": { + "type": "boolean", + "description": "是否显示渐变效果。" + }, + "show_radius": { + "type": "boolean", + "description": "是否显示圆角,仅对柱形图和盈亏图生效。" + }, + "extremum_max": { + "type": "object", + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ] + }, + "extremum_min": { + "type": "object", + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ] + } + } + }, + "sparklines": { + "type": "array", + "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", + "items": { + "type": "object", + "description": "单个迷你图项。", + "properties": { + "sparkline_id": { + "type": "string", + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。" + }, + "position": { + "type": "object", + "description": "迷你图位置。create / update 时必填;delete 时省略。", + "properties": { + "row": { + "type": "number", + "minimum": 0, + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" + } + }, + "required": [ + "row", + "col" + ] + }, + "source": { + "type": "string", + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。" + }, + "source_range": { + "type": "object", + "description": "结构化数据源范围(与 source 等价)。", + "properties": { + "range": { + "type": "string", + "description": "数据源的 A1 引用区域" + } + }, + "required": [ + "range" + ] + } + } + } + } + } + } + }, + "+sparkline-update": { + "properties": { + "description": "创建/更新/部分删除的迷你图属性。delete 时不传 sparklines 即删整组,传则删指定项。", + "type": "object", + "properties": { + "config": { + "type": "object", + "description": "迷你图样式配置, 相同 groupId 的迷你图共享相同的样式。", + "properties": { + "theme_type": { + "type": "string", + "enum": [ + "pro", + "light", + "soft", + "brand", + "fresh" + ], + "description": "主题类型:pro、light、soft、brand、fresh。" + }, + "non_num_show_as": { + "type": "string", + "enum": [ + "zero", + "gap", + "average" + ], + "description": "非数字单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + }, + "empty_show_as": { + "type": "string", + "enum": [ + "zero", + "gap", + "average" + ], + "description": "空单元格显示方式:zero=显示为0,gap=显示为间距,average=取前后均值。" + }, + "contain_hidden_cells": { + "type": "boolean", + "description": "隐藏的单元格数据是否参与绘制。" + }, + "series_color": { + "type": "string", + "description": "主系列颜色,例如 \"#4472C4\"。" + }, + "points": { + "type": "object", + "description": "特殊点样式配置,包含高点、低点、标记点、首点、尾点、负点。", + "properties": { + "last_point": { + "type": "object", + "description": "尾点配置,最后一个数据点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "negative_point": { + "type": "object", + "description": "负点配置,负数点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "markers_point": { + "type": "object", + "description": "标记点配置,所有标记点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "first_point": { + "type": "object", + "description": "首点配置,第一个数据点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "high_point": { + "type": "object", + "description": "高点配置,最高点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + }, + "low_point": { + "type": "object", + "description": "低点配置,最低点的样式。", + "properties": { + "color": { + "type": "string", + "description": "点的颜色。" + }, + "visible": { + "type": "boolean", + "description": "是否显示该点。" + } + } + } + } + }, + "line_width": { + "type": "number", + "enum": [ + 1, + 2, + 3, + 4 + ], + "description": "折线图线宽,可选值:1=1px,2=2px,3=3px,4=4px。" + }, + "type": { + "type": "string", + "enum": [ + "line", + "column", + "win_loss" + ], + "description": "迷你图类型,可选值:line=折线图,column=柱形图,win_loss=盈亏图。" + }, + "axis": { + "type": "object", + "description": "坐标轴配置,包含坐标轴颜色、是否翻转、是否显示坐标轴。", + "properties": { + "color": { + "type": "string", + "description": "坐标轴颜色。" + }, + "reverse": { + "type": "boolean", + "description": "是否翻转坐标轴方向。" + }, + "visible": { + "type": "boolean", + "description": "是否显示坐标轴。" + } + } + }, + "show_gradient": { + "type": "boolean", + "description": "是否显示渐变效果。" + }, + "show_radius": { + "type": "boolean", + "description": "是否显示圆角,仅对柱形图和盈亏图生效。" + }, + "extremum_max": { + "type": "object", + "description": "最大极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型,可选值:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ] + }, + "extremum_min": { + "type": "object", + "description": "最小极值配置,包含极值类型、极值。", + "properties": { + "type": { + "type": "string", + "enum": [ + "individual", + "group", + "custom" + ], + "description": "极值类型:individual=单个迷你图极值,group=同组内极值,custom=自定义。" + }, + "value": { + "type": "number", + "description": "当 type='custom' 时生效的具体数值。" + } + }, + "required": [ + "type" + ] + } + } + }, + "sparklines": { + "type": "array", + "description": "迷你图项列表。create 时为待创建项(每项需 position + source/source_range);update 时为待变更/新增项(每项需 sparkline_id;upsert=true 新增时需 position + source);delete 时为待删除项(每项需 sparkline_id)。", + "items": { + "type": "object", + "description": "单个迷你图项。", + "properties": { + "sparkline_id": { + "type": "string", + "description": "迷你图 reference_id。create 时可选(不传系统生成);update / delete 时必填。" + }, + "position": { + "type": "object", + "description": "迷你图位置。create / update 时必填;delete 时省略。", + "properties": { + "row": { + "type": "number", + "minimum": 0, + "description": "行索引(0-based)" + }, + "col": { + "type": "string", + "description": "列索引,例如 \"A\"、\"B\"" + } + }, + "required": [ + "row", + "col" + ] + }, + "source": { + "type": "string", + "description": "A1 范围字符串,表示数据来源,例如 \"Sheet1!A2:A10\"。create 必填(与 source_range 二选一);update 仅在改源时传;delete 时省略。" + }, + "source_range": { + "type": "object", + "description": "结构化数据源范围(与 source 等价)。", + "properties": { + "range": { + "type": "string", + "description": "数据源的 A1 引用区域" + } + }, + "required": [ + "range" + ] + } + } + } + } + } + } + } + } +} diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go new file mode 100644 index 000000000..8cd24bb25 --- /dev/null +++ b/shortcuts/sheets/execute_paths_test.go @@ -0,0 +1,578 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and +// verifies the shortcut decodes the JSON-string output, surfaces it as +// envelope data, and finishes without error. +func TestExecute_WorkbookInfo_Happy(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","row_count":1000,"column_count":26,"index":0}]}`) + out, err := runShortcutWithStubs(t, WorkbookInfo, []string{"--url", testURL}, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + sheets, _ := data["sheets"].([]interface{}) + if len(sheets) != 1 { + t.Fatalf("sheets len = %d, want 1", len(sheets)) + } + sheet, _ := sheets[0].(map[string]interface{}) + if sheet["sheet_id"] != "sh1" || sheet["title"] != "Sheet1" { + t.Errorf("unexpected sheet: %#v", sheet) + } +} + +// TestExecute_WorkbookInfo_ToolError surfaces a non-zero code in the +// envelope shape and asserts CLI returns an error envelope. +func TestExecute_WorkbookInfo_ToolError(t *testing.T) { + t.Parallel() + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read", + Body: map[string]interface{}{ + "code": 1310201, + "msg": "spreadsheet not found", + "data": map[string]interface{}{}, + }, + } + stdout, stderr, err := func() (string, string, error) { + parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo) + reg.Register(stub) + parent.SetArgs([]string{"+workbook-info", "--url", testURL}) + err := parent.Execute() + return stdout.String(), stderr.String(), err + }() + if err == nil { + t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") { + t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove +// when only --sheet-name is given (and --source-index omitted) first +// reads the workbook structure to derive sheet_id + source_index, then +// posts the modify_workbook_structure call. +func TestExecute_SheetMove_LookupsIndex(t *testing.T) { + t.Parallel() + lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","sheet_name":"汇总","index":3}]}`) + move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`) + out, err := runShortcutWithStubs(t, SheetMove, + []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"}, + lookup, move, + ) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + // Inspect the captured move body: source_index should be 3 (looked up), + // not , and sheet_id should be the resolved id. + if move.CapturedBody == nil { + t.Fatal("move stub didn't capture a body") + } + body := decodeRawEnvelopeBody(t, move.CapturedBody) + input := decodeToolInput(t, body, "modify_workbook_structure") + if input["sheet_id"] != "sh1" { + t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name)", input["sheet_id"]) + } + if input["source_index"].(float64) != 3 { + t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"]) + } + if input["target_index"].(float64) != 0 { + t.Errorf("target_index = %v, want 0", input["target_index"]) + } +} + +// TestExecute_SheetMove_LookupsIndexByTitle covers the same lookup path as +// above but with get_workbook_structure exposing the display name as "title" +// (the field the real tool returns) instead of "sheet_name". lookupSheetIndex +// must resolve --sheet-name against either key. +func TestExecute_SheetMove_LookupsIndexByTitle(t *testing.T) { + t.Parallel() + lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"汇总","index":3}]}`) + move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`) + out, err := runShortcutWithStubs(t, SheetMove, + []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"}, + lookup, move, + ) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + if move.CapturedBody == nil { + t.Fatal("move stub didn't capture a body") + } + body := decodeRawEnvelopeBody(t, move.CapturedBody) + input := decodeToolInput(t, body, "modify_workbook_structure") + if input["sheet_id"] != "sh1" { + t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name via title)", input["sheet_id"]) + } + if input["source_index"].(float64) != 3 { + t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"]) + } +} + +// TestExecute_CellsGet covers a multi-range read end-to-end. +func TestExecute_CellsGet(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "read", `{"ranges":[{"range":"A1:B2","cells":[[{"value":1}]]}]}`) + out, err := runShortcutWithStubs(t, CellsGet, + []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + if data := decodeEnvelopeData(t, out); data["ranges"] == nil { + t.Fatalf("expected ranges in output; got=%#v", data) + } +} + +// TestExecute_CellsSet covers the write path including allow-overwrite +// override. +func TestExecute_CellsSet(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"updated_cells":2}`) + out, err := runShortcutWithStubs(t, CellsSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B1", + "--cells", `[[{"value":"x"},{"value":"y"}]]`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "set_cell_range") + if input["range"] != "A1:B1" { + t.Errorf("wire range = %v", input["range"]) + } + if data := decodeEnvelopeData(t, out); data["updated_cells"].(float64) != 2 { + t.Errorf("updated_cells = %v", data["updated_cells"]) + } +} + +// TestExecute_DropdownSet covers the fan-out → set_cell_range write. +func TestExecute_DropdownSet(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{}`) + _, err := runShortcutWithStubs(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", + "--options", `["x","y"]`, + "--multiple", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + if len(cells) != 3 { + t.Errorf("wire cells rows = %d, want 3", len(cells)) + } +} + +// TestExecute_DropdownUpdate_Batch covers the batch_update fan-out for +// dropdown-update. Verifies the captured request has 2 ops. +func TestExecute_DropdownUpdate_Batch(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true},{"ok":true}]}`) + _, err := runShortcutWithStubs(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`, + "--options", `["a","b"]`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 2 { + t.Errorf("operations len = %d, want 2", len(ops)) + } +} + +// TestExecute_CellsSearch covers the search read path with options. +func TestExecute_CellsSearch(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "read", `{"matches":[{"cell":"B2"}],"has_more":false}`) + out, err := runShortcutWithStubs(t, CellsSearch, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--find", "foo", "--match-case", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + data := decodeEnvelopeData(t, out) + if data["matches"] == nil { + t.Errorf("matches missing: %#v", data) + } +} + +// TestExecute_RangeMove covers the transform_range write path. +func TestExecute_RangeMove(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"moved":true}`) + out, err := runShortcutWithStubs(t, RangeMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "A1:C5", + "--target-range", "D1", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "transform_range") + if input["operation"] != "move" { + t.Errorf("operation = %v, want move", input["operation"]) + } +} + +// TestExecute_FilterCreate covers the filter special case (range mandatory, +// optional --data conditions merge). +func TestExecute_FilterCreate(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"filter_id":"sh1"}`) + out, err := runShortcutWithStubs(t, FilterCreate, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:F100", + "--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["x"]}]}]}`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "manage_filter_object") + props, _ := input["properties"].(map[string]interface{}) + if props["range"] != "A1:F100" { + t.Errorf("properties.range = %v", props["range"]) + } + if props["rules"] == nil { + t.Errorf("rules missing: %#v", props) + } +} + +// TestExecute_BatchUpdate_Translated covers the CLI-shape → MCP-shape +// translation: user passes {shortcut, input}, batchOpDispatch maps it to +// {tool_name, input(+operation, +excel_id)} before the tool call. Also +// verifies --continue-on-error. +func TestExecute_BatchUpdate_Translated(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) + _, err := runShortcutWithStubs(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}]`, + "--continue-on-error", + "--yes", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "batch_update") + if input["continue_on_error"] != true { + t.Errorf("continue_on_error not propagated: %#v", input) + } + ops, _ := input["operations"].([]interface{}) + if len(ops) != 1 { + t.Fatalf("operations length = %d, want 1", len(ops)) + } + op := ops[0].(map[string]interface{}) + if op["tool_name"] != "set_cell_range" { + t.Errorf("op.tool_name = %v, want set_cell_range (translated from +cells-set)", op["tool_name"]) + } + subInput, _ := op["input"].(map[string]interface{}) + if subInput["excel_id"] != testToken { + t.Errorf("op.input.excel_id = %v, want %s (translator should inject)", subInput["excel_id"], testToken) + } + if _, has := subInput["operation"]; has { + t.Errorf("op.input.operation present but +cells-set should not inject one: %#v", subInput) + } +} + +// TestExecute_BatchUpdate_ContinueOnErrorPrecedence locks the flag-vs-envelope +// precedence: an explicit --continue-on-error=false must keep the strict +// transaction even when the --operations envelope carries continue_on_error:true, +// while an envelope value still applies when the flag is absent. Guards against +// the regression where the flag was read by value (runtime.Bool) rather than by +// Changed(). +func TestExecute_BatchUpdate_ContinueOnErrorPrecedence(t *testing.T) { + t.Parallel() + envelope := `{"operations":[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}],"continue_on_error":true}` + + t.Run("explicit false overrides envelope", func(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) + _, err := runShortcutWithStubs(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", envelope, + "--continue-on-error=false", + "--yes", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update") + if input["continue_on_error"] == true { + t.Errorf("explicit --continue-on-error=false must win over envelope; got continue_on_error=%#v", input["continue_on_error"]) + } + }) + + t.Run("envelope applies when flag absent", func(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`) + _, err := runShortcutWithStubs(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", envelope, + "--yes", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update") + if input["continue_on_error"] != true { + t.Errorf("envelope continue_on_error:true should apply when --continue-on-error absent; got %#v", input["continue_on_error"]) + } + }) +} + +// TestExecute_WorkbookCreate covers the create POST + first-sheet lookup + +// set_cell_range follow-up. Stubs all three endpoints. +func TestExecute_WorkbookCreate(t *testing.T) { + t.Parallel() + create := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcnBRAND", + "title": "Sales", + }, + }, + }, + } + // Initial fill first reads the workbook structure to resolve the default + // sheet's id (the create response doesn't echo it), then writes. + structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`) + fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`) + out, err := runShortcutWithStubs(t, WorkbookCreate, []string{ + "--title", "Sales", + "--headers", `["Name","Score"]`, + "--values", `[["alice",95]]`, + }, create, structure, fill) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + ss, _ := data["spreadsheet"].(map[string]interface{}) + if ss["spreadsheet_token"] != "shtcnBRAND" { + t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"]) + } + if data["initial_fill"] == nil { + t.Errorf("initial_fill missing in envelope") + } + // The fill must target the resolved first sheet, not an empty selector. + fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range") + if fillInput["sheet_id"] != "shtFirst" { + t.Errorf("fill sheet_id = %v, want shtFirst (resolved from workbook structure)", fillInput["sheet_id"]) + } +} + +// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map +// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit +// the initial fill (no structure/fill calls fire) and finish with the +// spreadsheet created but no initial_fill — never panic on a nil fill map. +func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ name, flag, val string }{ + {"empty values", "--values", "[]"}, + {"empty headers", "--headers", "[]"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + create := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"}, + }, + }, + } + // Only the create stub is provided: an empty array must skip the fill + // entirely, so no structure/fill call fires (and no nil-map panic). + out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", tc.flag, tc.val}, create) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + data := decodeEnvelopeData(t, out) + if data["initial_fill"] != nil { + t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"]) + } + if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" { + t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"]) + } + }) + } +} + +// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success +// contract: when the spreadsheet is created but the follow-up fill can't resolve +// its first sheet, the error must be structured and retain spreadsheet_token so +// the caller can recover instead of orphaning the new workbook. +func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) { + t.Parallel() + create := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"}, + }, + }, + } + // Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the + // spreadsheet already exists — exercising the partial-success path. + structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`) + out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure) + if err == nil { + t.Fatalf("expected a partial-success error; got nil\nout=%s", out) + } + exitErr, ok := err.(*output.ExitError) + if !ok { + t.Fatalf("error type = %T, want *output.ExitError (structured)", err) + } + if exitErr.Detail == nil { + t.Fatal("ExitError.Detail is nil; want structured detail carrying the token") + } + detail, _ := exitErr.Detail.Detail.(map[string]interface{}) + if detail["spreadsheet_token"] != "shtNEW" { + t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"]) + } +} + +// TestExecute_DimMove covers the native v3 move_dimension call. CLI's +// --source-range "1:3" (1-based inclusive) is parsed into v3's +// source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is +// parsed into destination_index=10. +func TestExecute_DimMove(t *testing.T) { + t.Parallel() + move := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"moved": true}, + }, + } + _, err := runShortcutWithStubs(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "1:3", "--target", "11", + }, move) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, move.CapturedBody) + src, _ := body["source"].(map[string]interface{}) + if src["start_index"].(float64) != 0 || src["end_index"].(float64) != 2 { + t.Errorf("indices = (%v,%v), want (0,2) — 0-based inclusive", src["start_index"], src["end_index"]) + } + if body["destination_index"].(float64) != 10 { + t.Errorf("destination_index = %v, want 10", body["destination_index"]) + } +} + +// TestExecute_ChartCreate covers the object-CRUD factory's create path. +func TestExecute_ChartCreate(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`) + out, err := runShortcutWithStubs(t, ChartCreate, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + data := decodeEnvelopeData(t, out) + if data["chart_id"] != "chartNEW" { + t.Errorf("chart_id = %v", data["chart_id"]) + } +} + +// TestExecute_SheetCreate hits the workbook write path with all four +// optional flags so the input builder + callTool wiring is exercised. +func TestExecute_SheetCreate(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"sheet_id":"sh99","sheet_name":"Q4","index":2}`) + out, err := runShortcutWithStubs(t, SheetCreate, []string{ + "--url", testURL, + "--title", "Q4", + "--index", "2", + "--row-count", "300", + "--col-count", "12", + }, stub) + if err != nil { + t.Fatalf("execute failed: %v\nout=%s", err, out) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "modify_workbook_structure") + if input["operation"] != "create" || input["sheet_name"] != "Q4" { + t.Errorf("input shape wrong: %#v", input) + } + if input["rows"].(float64) != 300 || input["columns"].(float64) != 12 { + t.Errorf("dimensions = (%v, %v), want (300, 12)", input["rows"], input["columns"]) + } +} + +// TestExecute_RangeSort exercises the sort_conditions JSON parsing +// alongside the boolean has_header. +func TestExecute_RangeSort(t *testing.T) { + t.Parallel() + stub := toolOutputStub(testToken, "write", `{"sorted":true}`) + _, err := runShortcutWithStubs(t, RangeSort, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:D50", + "--has-header", + "--sort-keys", `[{"column":"B","ascending":true}]`, + }, stub) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + body := decodeRawEnvelopeBody(t, stub.CapturedBody) + input := decodeToolInput(t, body, "transform_range") + if input["operation"] != "sort" || input["has_header"] != true { + t.Errorf("input wrong: %#v", input) + } + conds, _ := input["sort_conditions"].([]interface{}) + if len(conds) != 1 { + t.Errorf("sort_conditions len = %d", len(conds)) + } +} + +// decodeRawEnvelopeBody parses the raw JSON request body captured by an +// httpmock stub. Used by execute tests to inspect what the CLI sent on +// the wire (vs. dry-run tests that render the body up-front). +func decodeRawEnvelopeBody(t *testing.T, raw []byte) map[string]interface{} { + t.Helper() + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("captured body parse error: %v\nraw=%s", err, string(raw)) + } + return body +} diff --git a/shortcuts/sheets/flag_defs.go b/shortcuts/sheets/flag_defs.go new file mode 100644 index 000000000..9aa371af3 --- /dev/null +++ b/shortcuts/sheets/flag_defs.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "fmt" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── flag definitions, sourced from sheet-skill-spec ─────────────────── +// +// data/flag-defs.json is the canonical, full definition of every CLI flag +// (name, type, default, desc, enum, input, hidden, required, kind), +// generated by sheet-skill-spec's sync script. The sync script also emits +// flag_defs_gen.go — the compiled `flagDefs` map — so command startup pays +// no JSON unmarshal (the parse cost used to land on every CLI invocation, +// sheets or not). We build each shortcut's []common.Flag from flagDefs at +// assembly time, so flag metadata never has to be hand-written in Go. +// +// Flags with kind == "system" (--dry-run, --yes, ...) are NOT materialized +// here: the framework auto-injects them based on Risk / DryRun / HasFormat. +// Do not hand-edit flag_defs_gen.go or data/flag-defs.json; regenerate via +// the sync script. flag_defs_gen_test.go guards the two against drift. + +type flagDef struct { + Name string `json:"name"` + Kind string `json:"kind"` // "public" | "own" | "system" + Type string `json:"type"` // string | bool | int | int64 | float64 | string_array | string_slice + Required string `json:"required"` // "required" | "optional" | "xor" + Desc string `json:"desc"` + Default string `json:"default"` + Hidden bool `json:"hidden"` + Enum []string `json:"enum"` + Input []string `json:"input"` +} + +type commandDef struct { + Risk string `json:"risk"` + Flags []flagDef `json:"flags"` +} + +// loadFlagDefs returns the compiled flag definitions (flag_defs_gen.go). +// The error return is always nil; it is retained so existing call sites that +// handled a parse error keep compiling. There is no longer a runtime parse. +func loadFlagDefs() (map[string]commandDef, error) { + return flagDefs, nil +} + +// flagsFor builds the []common.Flag for a shortcut command directly from +// flag-defs.json. System-kind flags are skipped (the framework injects +// them). Panics if the command is absent or the JSON is malformed — this +// is a build-time data contract, so a missing entry is a programming error +// surfaced loudly at startup rather than a silent empty flag set. +func flagsFor(command string) []common.Flag { + defs, err := loadFlagDefs() + if err != nil { + panic(fmt.Sprintf("sheets: %v", err)) + } + spec, ok := defs[command] + if !ok { + panic(fmt.Sprintf("sheets: no flag-defs.json entry for %q", command)) + } + out := make([]common.Flag, 0, len(spec.Flags)) + for _, df := range spec.Flags { + if df.Kind == "system" { + continue + } + out = append(out, common.Flag{ + Name: df.Name, + Type: df.Type, + Default: df.Default, + Desc: df.Desc, + Hidden: df.Hidden, + Required: df.Required == "required", + Enum: df.Enum, + Input: df.Input, + }) + } + return out +} diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go new file mode 100644 index 000000000..686ffdf2e --- /dev/null +++ b/shortcuts/sheets/flag_defs_gen.go @@ -0,0 +1,927 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from data/flag-defs.json; DO NOT EDIT. + +package sheets + +// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's +// metadata for every shortcut, emitted as a Go literal so command startup +// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate +// with `go generate ./shortcuts/sheets/...` after data/flag-defs.json +// changes. +var flagDefs = map[string]commandDef{ + "+batch-update": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"}, + {Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets --help; for composite JSON flags use --print-schema --flag-name . Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}}, + {Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"}, + }, + }, + "+cells-batch-clear": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}}, + {Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-batch-set-style": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}}, + {Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"}, + {Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"}, + {Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"}, + {Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}}, + {Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}}, + {Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}}, + {Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}}, + {Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}}, + {Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}}, + {Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"}, + {Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-clear": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"}, + {Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-get": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"}, + {Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}}, + {Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true}, + {Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-merge": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"}, + {Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-replace": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"}, + {Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"}, + {Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"}, + {Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"}, + {Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"}, + {Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also replace within formula text"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace"}, + }, + }, + "+cells-search": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"}, + {Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"}, + {Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"}, + {Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"}, + {Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also search within formula text"}, + {Name: "max-matches", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 5000", Default: "5000", Hidden: true}, + {Name: "offset", Kind: "own", Type: "int", Required: "optional", Desc: "Skip the first N matches (for pagination); default 0", Default: "0"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-set": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"}, + {Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}}, + {Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"}, + {Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true}, + {Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-set-image": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"}, + {Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"}, + {Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-set-style": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"}, + {Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"}, + {Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"}, + {Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"}, + {Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}}, + {Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}}, + {Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}}, + {Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}}, + {Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}}, + {Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}}, + {Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"}, + {Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cells-unmerge": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+chart-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"}, + }, + }, + "+chart-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+chart-list": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+chart-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cols-resize": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cond-format-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}}, + {Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cond-format-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cond-format-list": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+cond-format-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}}, + {Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+csv-get": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"}, + {Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true}, + {Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"}, + {Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"}, + {Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"}, + }, + }, + "+csv-put": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"}, + {Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}}, + {Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-freeze": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}}, + {Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-group": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"}, + {Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-hide": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-insert": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}}, + {Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"}, + {Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-move": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-ungroup": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dim-unhide": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dropdown-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dropdown-get": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dropdown-set": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"}, + {Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}}, + {Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}}, + {Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select; default `false`"}, + {Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."}, + {Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+dropdown-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}}, + {Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}}, + {Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}}, + {Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"}, + {Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."}, + {Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"}, + {Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-list": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-view-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"}, + {Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-view-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-view-list": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+filter-view-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"}, + {Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+float-image-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"}, + {Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"}, + {Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"}, + {Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"}, + {Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"}, + {Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"}, + {Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"}, + {Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"}, + {Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"}, + {Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"}, + {Name: "image", Kind: "own", Type: "string", Required: "xor", Desc: "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+float-image-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+float-image-list": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+float-image-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"}, + {Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"}, + {Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"}, + {Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"}, + {Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"}, + {Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"}, + {Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"}, + {Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"}, + {Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"}, + {Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"}, + {Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+pivot-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", Input: []string{"file", "stdin"}}, + {Name: "target-position", Kind: "own", Type: "string", Required: "optional", Desc: "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", Default: "A1"}, + {Name: "target-sheet-id", Kind: "own", Type: "string", Required: "xor", Desc: "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."}, + {Name: "target-sheet-name", Kind: "own", Type: "string", Required: "xor", Desc: "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."}, + {Name: "source", Kind: "own", Type: "string", Required: "required", Desc: "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both."}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+pivot-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+pivot-list": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+pivot-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id ` first, then patch)", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+range-copy": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"}, + {Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"}, + {Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"}, + {Name: "paste-type", Kind: "own", Type: "string", Required: "optional", Desc: "Paste content type (`+range-copy` only)", Default: "all", Enum: []string{"values", "formulas", "formats", "all"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+range-fill": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"}, + {Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"}, + {Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+range-move": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"}, + {Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"}, + {Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+range-sort": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"}, + {Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"\",\"ascending\":}, ...]`", Input: []string{"file", "stdin"}}, + {Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+rows-resize": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-copy": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"}, + {Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"}, + {Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; 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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-hide": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-info": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}}, + {Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-move": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"}, + {Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-rename": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-set-tab-color": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sheet-unhide": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sparkline-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sparkline-delete": { + Risk: "high-risk-write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"}, + {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sparkline-list": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+sparkline-update": { + Risk: "write", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, + {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, + {Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"}, + {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id ` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+workbook-create": { + Risk: "write", + Flags: []flagDef{ + {Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"}, + {Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"}, + {Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}}, + {Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+workbook-export": { + Risk: "read", + Flags: []flagDef{ + {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, + {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, + {Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}}, + {Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"}, + {Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, + "+workbook-info": { + Risk: "read", + 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: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, +} diff --git a/shortcuts/sheets/flag_defs_gen_test.go b/shortcuts/sheets/flag_defs_gen_test.go new file mode 100644 index 000000000..6f253f7fa --- /dev/null +++ b/shortcuts/sheets/flag_defs_gen_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + _ "embed" + "encoding/json" + "reflect" + "testing" +) + +// flagDefsJSONForTest embeds the source data only in tests; production code +// reads the compiled flagDefs map (flag_defs_gen.go) and never unmarshals. +// +//go:embed data/flag-defs.json +var flagDefsJSONForTest []byte + +// TestFlagDefsGen_MatchesJSON guards against drift between the compiled +// flagDefs map (flag_defs_gen.go) and its source data/flag-defs.json: if the +// JSON is regenerated without re-running the codegen (or vice versa), this +// fails. This equivalence is exactly what lets production code skip the +// runtime unmarshal. +func TestFlagDefsGen_MatchesJSON(t *testing.T) { + t.Parallel() + var fromJSON map[string]commandDef + if err := json.Unmarshal(flagDefsJSONForTest, &fromJSON); err != nil { + t.Fatalf("unmarshal flag-defs.json: %v", err) + } + if !reflect.DeepEqual(fromJSON, flagDefs) { + t.Error("compiled flagDefs differs from data/flag-defs.json; regenerate flag_defs_gen.go") + } +} diff --git a/shortcuts/sheets/flag_defs_test.go b/shortcuts/sheets/flag_defs_test.go new file mode 100644 index 000000000..2d0217849 --- /dev/null +++ b/shortcuts/sheets/flag_defs_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestFlagDefs_EmbedParses asserts the embedded flag-defs.json blob is valid +// JSON with at least one command entry. +func TestFlagDefs_EmbedParses(t *testing.T) { + t.Parallel() + defs, err := loadFlagDefs() + if err != nil { + t.Fatalf("loadFlagDefs error: %v", err) + } + if len(defs) == 0 { + t.Fatal("flag-defs.json has no command entries") + } +} + +// TestFlagsFor_SkipsSystemFlags verifies system-kind flags (--dry-run, --yes) +// are never materialized into a shortcut's Flags slice — the framework injects +// those based on Risk / DryRun. +func TestFlagsFor_SkipsSystemFlags(t *testing.T) { + t.Parallel() + for _, cmd := range []string{"+sheet-delete", "+batch-update", "+csv-get"} { + for _, f := range flagsFor(cmd) { + if f.Name == "dry-run" || f.Name == "yes" { + t.Errorf("%s: system flag --%s leaked into Flags", cmd, f.Name) + } + } + } +} + +// TestFlagsFor_MapsAllFields spot-checks that name/type/default/enum/input/ +// required/hidden are carried over from the JSON correctly. +func TestFlagsFor_MapsAllFields(t *testing.T) { + t.Parallel() + byName := func(cmd, name string) *common.Flag { + flags := flagsFor(cmd) + for i := range flags { + if flags[i].Name == name { + return &flags[i] + } + } + return nil + } + + // enum + default + rt := byName("+dim-insert", "inherit-style") + if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" { + t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt) + } + // required + title := byName("+sheet-create", "title") + if title == nil || !title.Required { + t.Errorf("+sheet-create --title should be required: %+v", title) + } + // xor is NOT cobra-required (enforced by Validate hooks) + url := byName("+sheet-create", "url") + if url == nil || url.Required { + t.Errorf("+sheet-create --url should not be cobra-required: %+v", url) + } + // hidden + int default + cap := byName("+cells-get", "max-chars") + if cap == nil || !cap.Hidden || cap.Default != "200000" { + t.Errorf("+cells-get --max-chars not mapped: %+v", cap) + } + // input sources + cells := byName("+cells-set", "cells") + if cells == nil || len(cells.Input) != 2 { + t.Errorf("+cells-set --cells should support file+stdin: %+v", cells) + } + // float64 type + fs := byName("+cells-set-style", "font-size") + if fs == nil || fs.Type != "float64" { + t.Errorf("+cells-set-style --font-size should be float64: %+v", fs) + } +} + +// TestFlagsFor_EveryRegisteredCommandHasDefs ensures every shortcut returned by +// Shortcuts() has a flag-defs.json entry and that its flags match the JSON's +// non-system flags exactly (name + type + required + default + hidden). This is +// the contract that lets shortcuts drop hand-written flag literals. +func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) { + t.Parallel() + defs, err := loadFlagDefs() + if err != nil { + t.Fatal(err) + } + for _, s := range Shortcuts() { + spec, ok := defs[s.Command] + if !ok { + t.Errorf("%s has no flag-defs.json entry", s.Command) + continue + } + want := map[string]flagDef{} + for _, df := range spec.Flags { + if df.Kind != "system" { + want[df.Name] = df + } + } + got := map[string]bool{} + for _, f := range s.Flags { + got[f.Name] = true + df, ok := want[f.Name] + if !ok { + t.Errorf("%s --%s present in Go but not in JSON (non-system)", s.Command, f.Name) + continue + } + ft := f.Type + if ft == "" { + ft = "string" + } + jt := df.Type + if jt == "" { + jt = "string" + } + if ft != jt { + t.Errorf("%s --%s type: go=%s json=%s", s.Command, f.Name, ft, jt) + } + if f.Required != (df.Required == "required") { + t.Errorf("%s --%s required: go=%v json=%s", s.Command, f.Name, f.Required, df.Required) + } + if f.Default != df.Default { + t.Errorf("%s --%s default: go=%q json=%q", s.Command, f.Name, f.Default, df.Default) + } + if f.Hidden != df.Hidden { + t.Errorf("%s --%s hidden: go=%v json=%v", s.Command, f.Name, f.Hidden, df.Hidden) + } + } + for name := range want { + if !got[name] { + t.Errorf("%s --%s in JSON but missing from Go Flags", s.Command, name) + } + } + } +} diff --git a/shortcuts/sheets/flag_schema.go b/shortcuts/sheets/flag_schema.go new file mode 100644 index 000000000..4fce37408 --- /dev/null +++ b/shortcuts/sheets/flag_schema.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + _ "embed" + "encoding/json" + "fmt" + "sort" + "sync" +) + +// ─── --print-schema runtime introspection ───────────────────────────── +// +// Composite JSON flags (--cells, --properties, --operations, --border-styles, +// --sort-keys) carry non-trivial structured payloads. Reference docs cover +// the top-level fields but agents often need the full JSON Schema to +// generate valid input. +// +// To serve that need without forcing every caller to fetch external docs, +// the spec repo ships a compact `flag-schemas.json` that extracts just the +// schema subtree corresponding to each (shortcut, flag) pair. We embed +// that artifact at compile time so `lark-cli sheets +// --print-schema --flag-name ` runs entirely locally. +// +// The artifact is generated by sheet-skill-spec's +// scripts/sync_to_consumers.mjs from canonical-spec/cli-flag-schema-map.json +// + tool-schemas/mcp-tools.json. Do not hand-edit data/flag-schemas.json; +// regenerate via the sync script. + +//go:embed data/flag-schemas.json +var flagSchemasJSON []byte + +// flagSchemaIndex parses lazily on first access; failures are surfaced +// as errors from the lookup helper rather than panicking at init time. +type flagSchemaIndex struct { + SchemaVersion string `json:"schema_version"` + Flags map[string]map[string]json.RawMessage `json:"flags"` +} + +// loadFlagSchemas is sync.Once-guarded so concurrent first access from +// parallel goroutines (e.g. parallel unit tests, parallel shortcut +// invocations) doesn't race on the lazy parse. +var ( + flagSchemasOnce sync.Once + parsedFlagSchemas *flagSchemaIndex + parseFlagErr error +) + +func loadFlagSchemas() (*flagSchemaIndex, error) { + flagSchemasOnce.Do(func() { + var idx flagSchemaIndex + if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil { + parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err) + return + } + if idx.Flags == nil { + idx.Flags = map[string]map[string]json.RawMessage{} + } + parsedFlagSchemas = &idx + }) + return parsedFlagSchemas, parseFlagErr +} + +// commandsWithFlagSchema returns the set of shortcut commands that have +// at least one introspectable flag. Used by Shortcuts() to decide which +// shortcuts to wire PrintFlagSchema into. +func commandsWithFlagSchema() map[string]struct{} { + idx, err := loadFlagSchemas() + if err != nil || idx == nil { + return nil + } + out := make(map[string]struct{}, len(idx.Flags)) + for cmd := range idx.Flags { + out[cmd] = struct{}{} + } + return out +} + +// printFlagSchemaFor returns a PrintFlagSchema closure bound to the given +// shortcut command. When flagName == "" the closure returns a JSON +// listing of introspectable flags; otherwise it returns the schema +// subtree JSON for the named flag, or an error if the flag is not +// registered. +func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) { + return func(flagName string) ([]byte, error) { + idx, err := loadFlagSchemas() + if err != nil { + return nil, err + } + entry, ok := idx.Flags[command] + if !ok || len(entry) == 0 { + return nil, fmt.Errorf("no JSON Schema registered for %s", command) + } + if flagName == "" { + flags := make([]string, 0, len(entry)) + for f := range entry { + flags = append(flags, f) + } + sort.Strings(flags) + return json.MarshalIndent(map[string]interface{}{ + "shortcut": command, + "introspectable_flags": flags, + "hint": "run again with --flag-name to dump the JSON Schema for that flag", + }, "", " ") + } + schema, ok := entry[flagName] + if !ok { + flags := make([]string, 0, len(entry)) + for f := range entry { + flags = append(flags, f) + } + sort.Strings(flags) + return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags) + } + // Reformat for readability — schema files store compact JSON. + var pretty interface{} + if err := json.Unmarshal(schema, &pretty); err != nil { + return nil, err + } + return json.MarshalIndent(pretty, "", " ") + } +} diff --git a/shortcuts/sheets/flag_schema_test.go b/shortcuts/sheets/flag_schema_test.go new file mode 100644 index 000000000..69e882da4 --- /dev/null +++ b/shortcuts/sheets/flag_schema_test.go @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +// TestFlagSchemas_EmbedParses asserts the synced flag-schemas.json +// embedded blob is valid JSON and has at least one shortcut/flag entry. +// If sync_to_consumers.mjs ever ships an empty or broken artifact, this +// catches it at build time of the test binary. +func TestFlagSchemas_EmbedParses(t *testing.T) { + t.Parallel() + idx, err := loadFlagSchemas() + if err != nil { + t.Fatalf("loadFlagSchemas error: %v", err) + } + if idx == nil || len(idx.Flags) == 0 { + t.Fatalf("flag-schemas.json has no entries") + } + if idx.SchemaVersion == "" { + t.Errorf("schema_version missing") + } + // Spot-check a couple of canonical entries we know upstream guarantees. + for _, want := range []string{"+cells-set", "+chart-create", "+batch-update"} { + if _, ok := idx.Flags[want]; !ok { + t.Errorf("missing shortcut entry %q (regenerate via sheet-skill-spec/scripts/sync_to_consumers.mjs)", want) + } + } +} + +// TestPrintFlagSchema_ListIntrospectable verifies that calling the +// closure with an empty flag name returns the JSON listing of +// introspectable flags for the shortcut. +func TestPrintFlagSchema_ListIntrospectable(t *testing.T) { + t.Parallel() + out, err := printFlagSchemaFor("+cells-set")("") + if err != nil { + t.Fatalf("err: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("output not JSON: %v\n%s", err, out) + } + if got["shortcut"] != "+cells-set" { + t.Errorf("shortcut = %v, want +cells-set", got["shortcut"]) + } + flags, _ := got["introspectable_flags"].([]interface{}) + if len(flags) == 0 || flags[0] != "cells" { + t.Errorf("introspectable_flags = %v, want [cells]", flags) + } +} + +// TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree verifies a hit on +// (+chart-create, properties) yields a JSON Schema object with the +// expected top-level fields. +func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) { + t.Parallel() + out, err := printFlagSchemaFor("+chart-create")("properties") + if err != nil { + t.Fatalf("err: %v", err) + } + var schema map[string]interface{} + if err := json.Unmarshal(out, &schema); err != nil { + t.Fatalf("output not JSON: %v\n%s", err, out) + } + if schema["type"] != "object" { + t.Errorf("schema.type = %v, want object", schema["type"]) + } + if _, ok := schema["properties"]; !ok { + t.Errorf("schema missing nested .properties: keys=%v", keysOf(schema)) + } +} + +// TestPrintFlagSchema_UnknownFlagListsAvailable confirms the error +// message tells the caller which flags exist for the shortcut. +func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) { + t.Parallel() + _, err := printFlagSchemaFor("+chart-create")("does-not-exist") + if err == nil { + t.Fatal("expected error for unknown flag, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") { + t.Errorf("error should mention shortcut + available flags; got %q", msg) + } +} + +// TestPrintFlagSchema_UnknownShortcut surfaces a missing shortcut entry. +func TestPrintFlagSchema_UnknownShortcut(t *testing.T) { + t.Parallel() + _, err := printFlagSchemaFor("+not-a-real-shortcut")("") + if err == nil { + t.Fatal("expected error for unknown shortcut") + } +} + +// TestShortcuts_AttachesPrintFlagSchema confirms the registration loop +// in Shortcuts() wires PrintFlagSchema onto each shortcut whose command +// has a schema entry, and leaves it nil for shortcuts that don't. +func TestShortcuts_AttachesPrintFlagSchema(t *testing.T) { + t.Parallel() + all := Shortcuts() + withSchema := commandsWithFlagSchema() + for _, s := range all { + _, expected := withSchema[s.Command] + got := s.PrintFlagSchema != nil + if got != expected { + t.Errorf("%s: PrintFlagSchema attached=%v, expected=%v", s.Command, got, expected) + } + } +} + +// TestPrintSchema_SystemFlagShortCircuit verifies the framework's +// --print-schema interception: required flags are relaxed, Validate / +// Execute are skipped, and the schema JSON appears on stdout. +func TestPrintSchema_SystemFlagShortCircuit(t *testing.T) { + t.Parallel() + // +cells-set has required --range / --cells / --sheet-id; without + // --print-schema, cobra would reject the call. With --print-schema, + // it should print the schema and exit cleanly. The PrintFlagSchema + // closure is normally attached by Shortcuts(), so we attach it here + // to mirror that registration path. + sc := CellsSet + sc.PrintFlagSchema = printFlagSchemaFor(sc.Command) + stdout, err := runShortcut(t, sc, []string{"--print-schema", "--flag-name", "cells"}) + if err != nil { + t.Fatalf("err: %v\nstdout=%s", err, stdout) + } + if !strings.Contains(stdout, "\"type\"") { + t.Errorf("expected JSON Schema with \"type\" key; got=%s", stdout) + } +} + +// TestPrintSchema_ListingWhenNoFlagNameGiven exercises the discovery +// path: `--print-schema` without `--flag-name` should list the +// shortcut's introspectable flags as JSON on stdout. +func TestPrintSchema_ListingWhenNoFlagNameGiven(t *testing.T) { + t.Parallel() + sc := CellsSet + sc.PrintFlagSchema = printFlagSchemaFor(sc.Command) + stdout, err := runShortcut(t, sc, []string{"--print-schema"}) + if err != nil { + t.Fatalf("err: %v\nstdout=%s", err, stdout) + } + var got map[string]interface{} + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("stdout not JSON: %v\n%s", err, stdout) + } + flags, _ := got["introspectable_flags"].([]interface{}) + if len(flags) == 0 { + t.Errorf("introspectable_flags empty: %#v", got) + } +} + +// TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut ensures we don't +// inject --print-schema onto shortcuts that have no composite flags. +// +workbook-info is read-only and not in the schema map. +func TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut(t *testing.T) { + t.Parallel() + _, _, err := runShortcutCapturingErr(t, WorkbookInfo, []string{"--url", testURL, "--print-schema"}) + if err == nil { + t.Fatal("expected unknown flag error") + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Errorf("expected 'unknown flag'; got %v", err) + } +} + +// TestPrintSchema_UnknownFlagNameIsStructured pins issue #6: an unregistered +// --flag-name passed to --print-schema must surface as a structured +// *output.ExitError (type print_schema_error), not a bare error string, so the +// agent-facing introspection path stays machine-parseable. +func TestPrintSchema_UnknownFlagNameIsStructured(t *testing.T) { + t.Parallel() + // PrintFlagSchema is wired during registration (shortcuts.go), not on the + // literal, so replicate that here to make Mount inject the --print-schema / + // --flag-name system flags. + sc := CellsSet + sc.PrintFlagSchema = printFlagSchemaFor(sc.Command) + _, _, err := runShortcutCapturingErr(t, sc, []string{ + "--print-schema", "--flag-name", "nonexistent", + }) + if err == nil { + t.Fatal("expected an error for --print-schema with an unregistered flag name") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("error type = %T, want a structured *output.ExitError", err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "print_schema_error" { + t.Errorf("error detail = %+v, want type print_schema_error", exitErr.Detail) + } +} + +func keysOf(m map[string]interface{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/shortcuts/sheets/flag_schema_validate.go b/shortcuts/sheets/flag_schema_validate.go new file mode 100644 index 000000000..fb3299f2c --- /dev/null +++ b/shortcuts/sheets/flag_schema_validate.go @@ -0,0 +1,500 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── schema-driven flag validation ──────────────────────────────────── +// +// Composite JSON flags (--properties, --cells, --operations, …) carry +// non-trivial payloads whose shape is already pinned by the embedded +// data/flag-schemas.json (see flag_schema.go). Rather than hand-write +// per-spec validators for type / enum / required / nested checks, every +// such flag is run through validatePropertiesAgainstSchema after the +// shortcut's enhance hook has filled in any flat-flag-derived fields +// (schema describes the *final* tool input, not the raw --properties +// JSON the user typed). Cross-field business rules that JSON Schema +// can't express (e.g. sparkline-update requires sparkline_id per item) +// continue to live in spec.validateUpdateInput. +// +// The rule set is a subset of ai-tools/.../validate-tool-params.ts — +// type, enum, oneOf, required, nested properties, and array items. +// additionalProperties is intentionally lenient: the embedded schema +// is a sub-tree and may not be exhaustive, so rejecting unknown keys +// would be more disruptive than valuable. + +// validateParsedJSONFlag validates the just-parsed value of a single +// JSON flag against its embedded schema, if one is registered for the +// (command, flag) pair. Called from parseJSONFlag so every JSON flag +// — sort-keys, options, border-styles, cells, operations, ranges, … — +// is checked at the user-input boundary, in user-input shape. +// +// `properties` is intentionally skipped here: its schema describes the +// *final* tool-input properties (the shape after enhance* hooks +// inject flat-flag-derived fields such as cond-format's rule_type), +// not what the user typed under --properties. The input-builder tail +// validates that one via validateInputAgainstSchema after enhance. +func validateParsedJSONFlag(fv flagView, name string, value interface{}) error { + if fv == nil || value == nil { + return nil + } + if _, skip := parseJSONFlagSkip[name]; skip { + return nil + } + return validateValueAgainstSchema(fv, name, value) +} + +// parseJSONFlagSkip lists flag names where parseJSONFlag-time schema +// validation is intentionally bypassed: +// +// - properties: schema describes the *final* tool-input shape (after +// enhance hooks inject flat-flag-derived fields); validated at the +// input-builder tail via validateInputAgainstSchema instead. +// - operations: +batch-update's translator does richer validation +// (allowed-shortcut allow-list, fan-out rejection, …) with more +// actionable error messages than a generic "not in enum [...]" +// would. The translator path stays the source of truth. +var parseJSONFlagSkip = map[string]struct{}{ + "properties": {}, + "operations": {}, +} + +// validateValueAgainstSchema is the (command, flag) → schema → check +// pipeline shared by both validateParsedJSONFlag (user shape) and +// validateInputAgainstSchema (wire shape). +func validateValueAgainstSchema(fv flagView, name string, value interface{}) error { + command := fv.Command() + if command == "" { + return nil + } + // Fast path: commands without a registered schema can't fail this check, + // so skip the 256KB flag-schemas.json parse entirely for them. + if _, ok := commandsWithSchema[command]; !ok { + return nil + } + idx, _ := loadFlagSchemas() + if idx == nil { + return nil + } + entry, ok := idx.Flags[command] + if !ok { + return nil + } + raw, ok := entry[name] + if !ok { + return nil + } + var schema schemaProperty + json.Unmarshal(raw, &schema) + if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil { + return common.FlagErrorf("--%s: %s", name, vErr.Error()) + } + return nil +} + +// validateInputAgainstSchema validates input[flag] for every flag the +// embedded schema registers under the view's shortcut command. Returns +// nil when no schema is registered for the command, or when none of +// the registered flag names appear in `input` (schema describes the +// shape of values when they are present, not which flags must be +// present). Designed to be called at the tail of every input builder +// so wiring up a new shortcut requires only the standard one-line +// invocation, not a per-shortcut validator. +func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error { + if fv == nil || input == nil { + return nil + } + command := fv.Command() + if command == "" { + return nil + } + // Fast path: commands without a registered schema have nothing to + // validate, so skip the 256KB flag-schemas.json parse entirely. + if _, ok := commandsWithSchema[command]; !ok { + return nil + } + idx, _ := loadFlagSchemas() + if idx == nil { + return nil + } + entry, ok := idx.Flags[command] + if !ok || len(entry) == 0 { + return nil + } + + // Deterministic order so error messages are stable across runs. + flagNames := make([]string, 0, len(entry)) + for name := range entry { + flagNames = append(flagNames, name) + } + sort.Strings(flagNames) + + for _, flagName := range flagNames { + if _, skip := inputSchemaSkip[flagName]; skip { + continue + } + // Input keys are wire-style (underscore); schema keys are CLI-style + // (hyphen) — translate before lookup. Flags whose wire form lives + // under a different key (e.g. --sort-keys → sort_conditions) won't + // be found here; they're already validated in user shape via + // parseJSONFlag → validateParsedJSONFlag. + inputKey := strings.ReplaceAll(flagName, "-", "_") + value, present := input[inputKey] + if !present { + continue + } + if err := validateValueAgainstSchema(fv, flagName, value); err != nil { + return err + } + } + return nil +} + +// inputSchemaSkip mirrors parseJSONFlagSkip for the input-builder +// tail. Same rationale: bypass schema validation for flags where +// richer translator-side validation owns the contract (operations). +var inputSchemaSkip = map[string]struct{}{ + "operations": {}, +} + +// schemaProperty mirrors the JSON Schema subset used by +// data/flag-schemas.json. Unknown keys (description, …) are dropped — +// they're documentation. +// +// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because +// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes +// "no bound declared" from "bound is zero". +// +// AdditionalProperties handles the JSON Schema three-way: +// - absent / true → lenient, any extra key allowed (validator's +// default; matches the file header's "may not be exhaustive" +// stance for schemas that simply don't declare it). +// - false → strict, every extra key rejected. +// - → extra keys allowed, but each value must validate +// against this schema. Used today for pivot's dynamic +// map> fields (groups / collapse). +type schemaProperty struct { + Type string `json:"type"` + Nullable bool `json:"nullable"` + Enum []interface{} `json:"enum"` + Properties map[string]*schemaProperty `json:"properties"` + Required []string `json:"required"` + Items *schemaProperty `json:"items"` + OneOf []*schemaProperty `json:"oneOf"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` + MinItems *int `json:"minItems"` + MaxItems *int `json:"maxItems"` + AdditionalProperties *additionalProps `json:"additionalProperties"` +} + +// additionalProps captures the three JSON Schema forms of +// `additionalProperties`. UnmarshalJSON decodes true / false / object +// into the same struct so callers can branch on (Strict, Schema). +type additionalProps struct { + Strict bool // true when schema declared additionalProperties:false + Schema *schemaProperty // non-nil when declared as an object schema +} + +func (a *additionalProps) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + switch trimmed { + case "true": + return nil // lenient — same as absent + case "false": + a.Strict = true + return nil + } + var sub schemaProperty + if err := json.Unmarshal(data, &sub); err != nil { + return err + } + a.Schema = &sub + return nil +} + +// validateAgainstSchema recursively checks `value` against `schema`, +// prefixing any failure with the JSON path navigated so far. +func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error { + if schema == nil { + return nil // defensive — current callers always pass &schema, but + // keeps validator safe for future programmatic construction. + } + if value == nil && schema.Nullable { + return nil + } + + if schema.Type != "" { + if !matchesJSONType(value, schema.Type) { + return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) + } + } + + // Numeric bounds — only checked when value is a number (type mismatch + // already reported above). Apply to both `number` and `integer` types. + if num, ok := value.(float64); ok { + if schema.Minimum != nil && num < *schema.Minimum { + return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) + } + if schema.Maximum != nil && num > *schema.Maximum { + return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) + } + } + + // Array length bounds — only checked when value is an array. + if arr, ok := value.([]interface{}); ok { + if schema.MinItems != nil && len(arr) < *schema.MinItems { + return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) + } + if schema.MaxItems != nil && len(arr) > *schema.MaxItems { + return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) + } + } + + if len(schema.Enum) > 0 { + matched := false + for _, allowed := range schema.Enum { + if jsonEqual(allowed, value) { + matched = true + break + } + } + 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 != "" { + msg += fmt.Sprintf(` (did you mean %q?)`, hint) + } + return fmt.Errorf("%s", msg) + } + } + + if len(schema.OneOf) > 0 { + matched := false + for _, sub := range schema.OneOf { + if validateAgainstSchema(value, sub, path) == nil { + matched = true + break + } + } + if !matched { + return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) + } + } + + // Object-level checks. `required` and `properties` are independent + // per JSON Schema: `required` enforces keys regardless of whether + // the schema also describes their per-key shape via `properties`. + if obj, ok := value.(map[string]interface{}); ok { + for _, key := range schema.Required { + if _, present := obj[key]; !present { + return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) + } + } + if schema.Properties != nil { + keys := make([]string, 0, len(schema.Properties)) + for k := range schema.Properties { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + sub := schema.Properties[key] + v, present := obj[key] + if !present { + continue + } + // Case-insensitive enum tolerance: when the value matches an + // allowed enum entry except for casing, rewrite it in place to + // the canonical spelling. The schema lists enums in their + // canonical (lower-case) form, so "SUM" / "COUNTA" would + // otherwise be rejected right here before the request is even + // sent; normalizing kills the whole pivot summarize_by "SUM vs + // sum" class. Genuinely-unknown values still fail below, with + // their own did-you-mean hint. + if sub != nil && len(sub.Enum) > 0 { + if canon := suggestEnumMatch(v, sub.Enum); canon != "" { + obj[key] = canon + v = canon + } + } + child := key + if path != "" { + child = path + "." + key + } + if err := validateAgainstSchema(v, sub, child); err != nil { + return err + } + } + } + // additionalProperties: enforce only when explicitly declared. + // Absent means lenient (matches the file header's stance). Sort + // extras so the first rejection is deterministic across runs. + if schema.AdditionalProperties != nil { + extras := make([]string, 0) + for key := range obj { + if _, declared := schema.Properties[key]; declared { + continue + } + extras = append(extras, key) + } + sort.Strings(extras) + for _, key := range extras { + if schema.AdditionalProperties.Strict { + return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) + } + if schema.AdditionalProperties.Schema != nil { + child := key + if path != "" { + child = path + "." + key + } + if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil { + return err + } + } + } + } + } + + if schema.Type == "array" && schema.Items != nil { + arr, ok := value.([]interface{}) + if !ok { + return nil // type mismatch already reported above. + } + for i, item := range arr { + child := fmt.Sprintf("%s[%d]", path, i) + if err := validateAgainstSchema(item, schema.Items, child); err != nil { + return err + } + } + } + + return nil +} + +func matchesJSONType(value interface{}, expected string) bool { + switch expected { + case "object": + _, ok := value.(map[string]interface{}) + return ok + case "array": + _, ok := value.([]interface{}) + return ok + case "string": + _, ok := value.(string) + return ok + case "number": + _, ok := value.(float64) + return ok + case "integer": + f, ok := value.(float64) + return ok && f == float64(int64(f)) + case "boolean": + _, ok := value.(bool) + return ok + case "null": + return value == nil + } + return true +} + +func jsType(value interface{}) string { + switch value.(type) { + case nil: + return "null" + case map[string]interface{}: + return "object" + case []interface{}: + return "array" + case string: + return "string" + case float64: + return "number" + case bool: + return "boolean" + } + return fmt.Sprintf("%T", value) +} + +func jsonEqual(a, b interface{}) bool { + ja, _ := json.Marshal(a) + jb, _ := json.Marshal(b) + return string(ja) == string(jb) +} + +// formatJSONValue is the "what you actually passed" half of an enum +// error. Strings get JSON-quoted ("SUM"); everything else (numbers, +// booleans, null, objects, arrays) gets its JSON encoding. Marshal +// failure falls back to %v so we never panic just to format an error. +func formatJSONValue(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} + +// formatEnum renders the allowed-values list for an enum error. Caps +// the visible entries at enumDisplayLimit so a 50-shortcut enum +// doesn't bury the actual error in a wall of options; the overflow +// hint tells the user how many more exist (and to consult --help / +// --print-schema for the full list). +const enumDisplayLimit = 8 + +func formatEnum(values []interface{}) string { + if len(values) <= enumDisplayLimit { + return "[" + joinFormatted(values) + "]" + } + shown := values[:enumDisplayLimit] + return fmt.Sprintf("[%s, … (%d more)]", joinFormatted(shown), len(values)-enumDisplayLimit) +} + +func joinFormatted(values []interface{}) string { + parts := make([]string, 0, len(values)) + for _, v := range values { + parts = append(parts, formatJSONValue(v)) + } + 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. +func suggestEnumMatch(value interface{}, values []interface{}) string { + s, ok := value.(string) + if !ok { + return "" + } + lower := strings.ToLower(s) + 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 + } + } + } + return "" +} + +func pathPrefix(path string) string { + if path == "" { + return "" + } + return path + ": " +} + +func pathOrRoot(path string) string { + if path == "" { + return "(root)" + } + return path +} diff --git a/shortcuts/sheets/flag_schema_validate_test.go b/shortcuts/sheets/flag_schema_validate_test.go new file mode 100644 index 000000000..1646950bc --- /dev/null +++ b/shortcuts/sheets/flag_schema_validate_test.go @@ -0,0 +1,589 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "strings" + "testing" +) + +// parseSchema is a tiny test helper: take an inline JSON Schema string, +// hand back a *schemaProperty for validateAgainstSchema. Lets test +// cases declare their schema inline rather than hand-building structs. +func parseSchema(t *testing.T, raw string) *schemaProperty { + t.Helper() + var s schemaProperty + if err := json.Unmarshal([]byte(raw), &s); err != nil { + t.Fatalf("bad inline schema %q: %v", raw, err) + } + return &s +} + +// parseValue decodes a JSON literal the same way encoding/json gives +// validateAgainstSchema its input (numbers → float64, objects → +// map[string]interface{}, arrays → []interface{}). +func parseValue(t *testing.T, raw string) interface{} { + t.Helper() + var v interface{} + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatalf("bad inline value %q: %v", raw, err) + } + return v +} + +// TestValidateAgainstSchema_EnumCaseNormalization pins the case-insensitive +// enum tolerance: a value matching an allowed enum entry except for casing is +// rewritten in place to the canonical spelling (so the case-sensitive backend +// accepts it), while genuinely-unknown values still fail. Only fires for enum +// fields nested in an object/array — the pivot values[].summarize_by path. +func TestValidateAgainstSchema_EnumCaseNormalization(t *testing.T) { + t.Parallel() + + schema := parseSchema(t, `{"type":"object","properties":{"summarize_by":{"type":"string","enum":["sum","count","average"]}}}`) + + t.Run("rewrites case-only mismatch in place", func(t *testing.T) { + obj := map[string]interface{}{"summarize_by": "SUM"} + if err := validateAgainstSchema(obj, schema, ""); err != nil { + t.Fatalf("case-only value should pass after normalization, got: %v", err) + } + if got := obj["summarize_by"]; got != "sum" { + t.Errorf("summarize_by = %q, want normalized %q", got, "sum") + } + }) + + t.Run("leaves exact match untouched", func(t *testing.T) { + obj := map[string]interface{}{"summarize_by": "count"} + if err := validateAgainstSchema(obj, schema, ""); err != nil { + t.Fatalf("exact match should pass: %v", err) + } + if got := obj["summarize_by"]; got != "count" { + t.Errorf("exact value mutated to %q", got) + } + }) + + t.Run("unknown value still fails", func(t *testing.T) { + obj := map[string]interface{}{"summarize_by": "COUNTA"} + if err := validateAgainstSchema(obj, schema, ""); err == nil { + t.Fatal("unknown enum value should fail") + } else if !strings.Contains(err.Error(), "not in enum") { + t.Errorf("want enum error, got: %v", err) + } + }) + + t.Run("normalizes inside array-of-objects (values[] shape)", func(t *testing.T) { + arrSchema := parseSchema(t, `{"type":"array","items":{"type":"object","properties":{"summarize_by":{"type":"string","enum":["sum","count"]}}}}`) + arr := []interface{}{ + map[string]interface{}{"summarize_by": "Sum"}, + map[string]interface{}{"summarize_by": "COUNT"}, + } + if err := validateAgainstSchema(arr, arrSchema, ""); err != nil { + t.Fatalf("array case normalization failed: %v", err) + } + if got := arr[0].(map[string]interface{})["summarize_by"]; got != "sum" { + t.Errorf("arr[0] summarize_by = %q, want sum", got) + } + if got := arr[1].(map[string]interface{})["summarize_by"]; got != "count" { + t.Errorf("arr[1] summarize_by = %q, want count", got) + } + }) +} + +// TestValidateAgainstSchema is the validator's contract test: every +// supported keyword (type, enum, oneOf, required, nested properties, +// array items, nullable, minimum/maximum, minItems/maxItems) gets a +// pass + fail case, and the failure message is asserted to mention +// the JSON path and the violated constraint. Together these pin the +// validator's behaviour without going through any shortcut wiring. +func TestValidateAgainstSchema(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + schema string + value string + wantOK bool + wantInErr string // substring required in error message when !wantOK + }{ + // ─── type ───────────────────────────────────────────────────── + {"type string ok", `{"type":"string"}`, `"hi"`, true, ""}, + {"type string wrong", `{"type":"string"}`, `42`, false, `expected type "string"`}, + {"type number ok", `{"type":"number"}`, `3.14`, true, ""}, + {"type number wrong", `{"type":"number"}`, `"x"`, false, `got "string"`}, + {"type integer ok", `{"type":"integer"}`, `5`, true, ""}, + {"type integer fractional rejected", `{"type":"integer"}`, `5.5`, false, `expected type "integer"`}, + {"type boolean ok", `{"type":"boolean"}`, `true`, true, ""}, + {"type array ok", `{"type":"array"}`, `[1,2]`, true, ""}, + {"type object ok", `{"type":"object"}`, `{"a":1}`, true, ""}, + + // ─── nullable short-circuit ─────────────────────────────────── + {"nullable null accepted", `{"type":"string","nullable":true}`, `null`, true, ""}, + {"nullable schema still type-checks non-null", `{"type":"string","nullable":true}`, `42`, false, `expected type "string"`}, + {"nullable schema accepts matching type", `{"type":"string","nullable":true}`, `"x"`, true, ""}, + {"null rejected when nullable not set", `{"type":"string"}`, `null`, false, `expected type "string"`}, + + // ─── enum ──────────────────────────────────────────────────── + {"enum hit", `{"type":"string","enum":["asc","desc"]}`, `"asc"`, true, ""}, + {"enum miss", `{"type":"string","enum":["asc","desc"]}`, `"sideways"`, false, `not in enum ["asc", "desc"]`}, + + // ─── oneOf ─────────────────────────────────────────────────── + {"oneOf string branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `"x"`, true, ""}, + {"oneOf number branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `7`, true, ""}, + {"oneOf no branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `true`, false, `oneOf alternatives`}, + + // ─── required ──────────────────────────────────────────────── + { + "required key present", + `{"type":"object","required":["a"],"properties":{"a":{"type":"string"}}}`, + `{"a":"x"}`, true, "", + }, + { + "required key missing", + `{"type":"object","required":["a"]}`, + `{}`, false, `required property "a"`, + }, + + // ─── nested properties recurse ─────────────────────────────── + { + "nested property wrong type", + `{"type":"object","properties":{"inner":{"type":"object","properties":{"x":{"type":"number"}}}}}`, + `{"inner":{"x":"oops"}}`, false, `inner.x: expected type "number"`, + }, + + // ─── array items recurse with [i] path ─────────────────────── + { + "array items ok", + `{"type":"array","items":{"type":"string"}}`, + `["a","b"]`, true, "", + }, + { + "array item wrong type pinpoints index", + `{"type":"array","items":{"type":"string"}}`, + `["a",2,"c"]`, false, `[1]: expected type "string"`, + }, + + // ─── numeric bounds (P0 additions) ─────────────────────────── + {"minimum ok", `{"type":"number","minimum":0}`, `0`, true, ""}, + {"minimum fail", `{"type":"number","minimum":0}`, `-1`, false, `below minimum`}, + {"maximum ok", `{"type":"number","maximum":100}`, `100`, true, ""}, + {"maximum fail", `{"type":"number","maximum":100}`, `101`, false, `above maximum`}, + {"minimum on integer", `{"type":"integer","minimum":10}`, `5`, false, `below minimum`}, + + // ─── array length bounds (P0 additions) ────────────────────── + {"minItems ok", `{"type":"array","minItems":1}`, `[1]`, true, ""}, + {"minItems fail", `{"type":"array","minItems":1}`, `[]`, false, `array has 0 items, minimum is 1`}, + {"maxItems ok", `{"type":"array","maxItems":3}`, `[1,2,3]`, true, ""}, + {"maxItems fail", `{"type":"array","maxItems":3}`, `[1,2,3,4]`, false, `array has 4 items, maximum is 3`}, + + // ─── combined bounds inside nested array of objects ────────── + { + "nested minimum in array item objects", + `{"type":"array","items":{"type":"object","properties":{"row":{"type":"integer","minimum":0}}}}`, + `[{"row":0},{"row":-1}]`, false, `[1].row: value -1 is below minimum 0`, + }, + + // ─── additionalProperties absent: lenient (default) ────────── + { + "extras allowed when additionalProperties absent", + `{"type":"object","properties":{"a":{"type":"string"}}}`, + `{"a":"x","whatever":42}`, true, "", + }, + + // ─── additionalProperties:false: strict mode ───────────────── + { + "extras allowed when additionalProperties:true (explicit)", + `{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":true}`, + `{"a":"x","extra":1}`, true, "", + }, + { + "extras rejected when additionalProperties:false", + `{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false}`, + `{"a":"x","typo":1}`, false, `unexpected property "typo"`, + }, + { + "declared property still accepted under strict mode", + `{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false}`, + `{"a":"x"}`, true, "", + }, + + // ─── additionalProperties:: extras must match ──────── + { + "extras pass when matching additionalProperties schema", + `{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":{"type":"array","items":{"type":"string"}}}`, + `{"name":"x","g1":["a","b"],"g2":["c"]}`, true, "", + }, + { + "extras fail when wrong type for additionalProperties schema", + `{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}`, + `{"g1":[1,2]}`, false, `g1[0]: expected type "string"`, + }, + { + "extras fail when value isn't even right kind", + `{"type":"object","additionalProperties":{"type":"array"}}`, + `{"key":"not-an-array"}`, false, `key: expected type "array"`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s := parseSchema(t, tc.schema) + v := parseValue(t, tc.value) + err := validateAgainstSchema(v, s, "") + if tc.wantOK { + if err != nil { + t.Fatalf("expected pass, got error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got pass", tc.wantInErr) + } + if !strings.Contains(err.Error(), tc.wantInErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantInErr) + } + }) + } +} + +// TestValidateAgainstSchema_EnumErrorEnhancements pins the three +// enum-error UX upgrades together: +// - the failing value is quoted in JSON form ("SUM", not bare SUM) +// - the allowed list is JSON-quoted ("sum", not bare sum) and gets +// truncated past 8 entries with an "N more" hint +// - case-only mismatches surface a `did you mean` suggestion +// pointing at the canonical spelling +func TestValidateAgainstSchema_EnumErrorEnhancements(t *testing.T) { + t.Parallel() + + t.Run("small enum is fully listed and quoted", func(t *testing.T) { + t.Parallel() + s := parseSchema(t, `{"type":"string","enum":["asc","desc"]}`) + err := validateAgainstSchema("sideways", s, "order") + if err == nil { + t.Fatal("expected enum violation") + } + msg := err.Error() + if !strings.Contains(msg, `value "sideways"`) { + t.Errorf("want failing value quoted; got %q", msg) + } + if !strings.Contains(msg, `["asc", "desc"]`) { + t.Errorf("want enum list comma+quote formatted; got %q", msg) + } + }) + + t.Run("large enum is truncated with overflow hint", func(t *testing.T) { + t.Parallel() + // 12 values; default enumDisplayLimit is 8. + s := parseSchema(t, `{"type":"string","enum":[ + "a","b","c","d","e","f","g","h","i","j","k","l" + ]}`) + err := validateAgainstSchema("z", s, "x") + if err == nil { + t.Fatal("expected enum violation") + } + msg := err.Error() + if !strings.Contains(msg, "4 more") { + t.Errorf("want overflow hint '4 more'; got %q", msg) + } + if strings.Contains(msg, `"i"`) || strings.Contains(msg, `"l"`) { + t.Errorf("want truncation to first 8; got %q", msg) + } + if !strings.Contains(msg, `"h"`) { // 8th entry should be present. + t.Errorf("want first 8 entries shown; got %q", msg) + } + }) + + t.Run("case-only mismatch produces did-you-mean hint", func(t *testing.T) { + t.Parallel() + s := parseSchema(t, `{"type":"string","enum":["sum","count","average"]}`) + err := validateAgainstSchema("SUM", s, "") + if err == nil { + t.Fatal("expected enum violation") + } + if !strings.Contains(err.Error(), `did you mean "sum"?`) { + t.Errorf("want did-you-mean hint; got %q", err.Error()) + } + }) + + t.Run("no did-you-mean when value is not a near miss", func(t *testing.T) { + t.Parallel() + s := parseSchema(t, `{"type":"string","enum":["sum","count"]}`) + err := validateAgainstSchema("BOGUS", s, "") + if err == nil { + t.Fatal("expected enum violation") + } + if strings.Contains(err.Error(), "did you mean") { + t.Errorf("want no hint for unrelated value; got %q", err.Error()) + } + }) + + t.Run("did-you-mean only triggers for strings (not numbers)", func(t *testing.T) { + t.Parallel() + s := parseSchema(t, `{"enum":[1,2,3]}`) + err := validateAgainstSchema(float64(4), s, "") + if err == nil { + t.Fatal("expected enum violation") + } + if strings.Contains(err.Error(), "did you mean") { + t.Errorf("numeric enum should not get casing hint; got %q", err.Error()) + } + // And the failing numeric value still surfaces in JSON form. + if !strings.Contains(err.Error(), "value 4 ") { + t.Errorf("want numeric value in error; got %q", err.Error()) + } + }) +} + +// TestValidateInputAgainstSchema_RealEnumCaseNormalized confirms the +// case-insensitive enum tolerance fires against the real embedded schema for +// the most common real-world miscue — pivot summarize_by upper-cased. "SUM" is +// rewritten to "sum" in place and the input passes; previously this surfaced a +// did-you-mean error, but in-place canonicalization fixes it so the agent's first try wins. +func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+pivot-create"} + in := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{"field": "A", "summarize_by": "SUM"}, + }, + }, + } + if err := validateInputAgainstSchema(fv, in); err != nil { + t.Fatalf("upper-case summarize_by should be normalized and pass, got: %v", err) + } + vals := in["properties"].(map[string]interface{})["values"].([]interface{}) + if got := vals[0].(map[string]interface{})["summarize_by"]; got != "sum" { + t.Errorf("summarize_by = %q, want normalized to %q", got, "sum") + } +} + +// 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 +// programmatic construction (or a malformed schema sub-tree decoded +// as a nil pointer inside oneOf) won't crash with a nil deref. +func TestValidateAgainstSchema_NilSchemaSafe(t *testing.T) { + t.Parallel() + if err := validateAgainstSchema("anything", nil, ""); err != nil { + t.Errorf("nil schema should noop; got %v", err) + } +} + +// TestValidateAgainstSchema_AdditionalPropertiesSortedFirstFailure +// asserts that when multiple extras violate additionalProperties:false, +// the *alphabetically first* extra is the one reported — without the +// sort, Go map iteration would make the failing key non-deterministic +// across runs and the error message would flake. +func TestValidateAgainstSchema_AdditionalPropertiesSortedFirstFailure(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"object", + "properties":{"declared":{"type":"string"}}, + "additionalProperties":false + }`) + // Three extras; "alpha" comes first when sorted. + value := parseValue(t, `{"declared":"ok","zeta":1,"alpha":2,"middle":3}`) + for i := 0; i < 30; i++ { + err := validateAgainstSchema(value, schema, "") + if err == nil { + t.Fatalf("iter %d: expected extras to be rejected", i) + } + if !strings.Contains(err.Error(), `"alpha"`) { + t.Fatalf("iter %d: expected alphabetically first extra to be reported; got %v", i, err) + } + } +} + +// TestValidateAgainstSchema_ArrayItemRequired pins that `required` +// fires inside array items too — the recursion path applies the same +// object-level rules at every level, so a missing key in items +// surfaces as `[i].missing` and not a silently-passed item. +func TestValidateAgainstSchema_ArrayItemRequired(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"array", + "items":{ + "type":"object", + "required":["id"], + "properties":{"id":{"type":"string"}} + } + }`) + value := parseValue(t, `[{"id":"a"},{"name":"b"}]`) + err := validateAgainstSchema(value, schema, "") + if err == nil { + t.Fatal("expected required violation on items[1]") + } + if !strings.Contains(err.Error(), `required property "id"`) || !strings.Contains(err.Error(), "[1]") { + t.Errorf("expected required-id at [1]; got %v", err) + } +} + +// TestValidateAgainstSchema_DeterministicPropertyOrder regresses the +// "iterate properties in sorted key order" guarantee so that the +// first-failure error message is stable across runs (Go map iteration +// is randomized — without the sort, a schema with two bad fields +// would non-deterministically report either one). +func TestValidateAgainstSchema_DeterministicPropertyOrder(t *testing.T) { + t.Parallel() + schema := parseSchema(t, `{ + "type":"object", + "properties":{ + "a":{"type":"string"}, + "b":{"type":"string"}, + "c":{"type":"string"} + } + }`) + value := parseValue(t, `{"a":1,"b":2,"c":3}`) + // Run many times; "a" must always be the reported field (sorted first). + for i := 0; i < 50; i++ { + err := validateAgainstSchema(value, schema, "") + if err == nil || !strings.Contains(err.Error(), "a:") { + t.Fatalf("iter %d: expected error mentioning 'a:'; got %v", i, err) + } + } +} + +// TestValidateInputAgainstSchema_RealSchema exercises the full +// (command, flag) lookup pipeline against the real embedded +// flag-schemas.json — confirms that an out-of-enum summarize_by +// surfaces a descriptive error all the way through, and that a +// well-formed input passes. Mirrors what shortcut tests check, but +// without booting cobra. +func TestValidateInputAgainstSchema_RealSchema(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+pivot-create"} + + // Schema-conformant: values[0].summarize_by="sum" is in enum. + good := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{"field": "A", "summarize_by": "sum"}, + }, + }, + } + if err := validateInputAgainstSchema(fv, good); err != nil { + t.Errorf("good input rejected: %v", err) + } + + // Schema-violating: a value with no case-only match still fails loudly + // (case normalization only rescues casing mistakes, not unknown words). + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{"field": "A", "summarize_by": "bogus"}, + }, + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected enum violation, got nil") + } + if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") { + t.Errorf("error = %q, want summarize_by + enum hint", err.Error()) + } +} + +// TestValidateInputAgainstSchema_RealMinItems exercises a P0 +// addition end-to-end: +pivot-create properties.values has +// minItems:1, so an explicit empty values array is rejected by the +// schema validator (previously slipped past). +func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+pivot-create"} + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{}, // minItems:1 violated + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected minItems violation for empty values, got nil") + } + if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") { + t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error()) + } +} + +// TestValidateInputAgainstSchema_RealMinimum exercises another P0 +// addition: +chart-create properties.position.row has minimum:0, so +// row:-1 must be rejected before the request hits the wire. +func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+chart-create"} + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "position": map[string]interface{}{"row": float64(-1), "col": "A"}, + "size": map[string]interface{}{"width": float64(400), "height": float64(300)}, + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected minimum violation for row:-1, got nil") + } + if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") { + t.Errorf("error = %q, want row + below-minimum hint", err.Error()) + } +} + +// TestValidateInputAgainstSchema_RealAdditionalProperties pins the +// additionalProperties: form against the real embedded +// schema. +pivot-create properties.collapse is declared as a dynamic +// map>; passing a non-string in any value +// must be rejected end-to-end. +func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+pivot-create"} + + good := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{map[string]interface{}{"field": "A", "summarize_by": "sum"}}, + "collapse": map[string]interface{}{"region": []interface{}{"NA", "EU"}}, + }, + } + if err := validateInputAgainstSchema(fv, good); err != nil { + t.Errorf("schema-conformant collapse rejected: %v", err) + } + + bad := map[string]interface{}{ + "properties": map[string]interface{}{ + "values": []interface{}{map[string]interface{}{"field": "A", "summarize_by": "sum"}}, + "collapse": map[string]interface{}{"region": []interface{}{"NA", 42}}, // 42 violates items.type=string + }, + } + err := validateInputAgainstSchema(fv, bad) + if err == nil { + t.Fatal("expected additionalProperties violation, got nil") + } + if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) { + t.Errorf("error = %q, want collapse + string-type hint", err.Error()) + } +} + +// TestValidateInputAgainstSchema_UnknownCommand returns nil — schema +// validation is opportunistic, an unknown command never errors. Lets +// shortcuts opt out simply by not registering a schema entry. +func TestValidateInputAgainstSchema_UnknownCommand(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+definitely-not-a-shortcut"} + if err := validateInputAgainstSchema(fv, map[string]interface{}{"properties": "anything"}); err != nil { + t.Errorf("unknown command should noop; got %v", err) + } +} + +// TestValidateInputAgainstSchema_SkipOperations confirms that the +// operations skip-list entry is honoured: even with a clearly +// malformed operations value, validateInputAgainstSchema is a no-op +// because translator-side validation owns that contract. +func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) { + t.Parallel() + fv := mapFlagView{command: "+batch-update"} + input := map[string]interface{}{ + "operations": "definitely-not-an-array", + } + if err := validateInputAgainstSchema(fv, input); err != nil { + t.Errorf("operations should be skipped; got %v", err) + } +} diff --git a/shortcuts/sheets/flag_schemas_gen.go b/shortcuts/sheets/flag_schemas_gen.go new file mode 100644 index 000000000..6dedf8e94 --- /dev/null +++ b/shortcuts/sheets/flag_schemas_gen.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from data/flag-schemas.json; DO NOT EDIT. + +package sheets + +// commandsWithSchema is the set of shortcut commands that have at least one +// introspectable composite flag in data/flag-schemas.json. Codegen'd so the +// registration loop (shortcuts.go) and the validate fast-path can gate on it +// without parsing the 256KB schema blob at startup (that parse used to run on +// every CLI invocation, sheets or not). The 256KB is now only unmarshaled +// on --print-schema or when validating a command that is in this set. Do not +// hand-edit; regenerate with `go generate ./shortcuts/sheets/...`. +var commandsWithSchema = map[string]struct{}{ + "+batch-update": {}, + "+cells-batch-set-style": {}, + "+cells-set": {}, + "+cells-set-style": {}, + "+chart-create": {}, + "+chart-update": {}, + "+cond-format-create": {}, + "+cond-format-update": {}, + "+dropdown-set": {}, + "+dropdown-update": {}, + "+filter-create": {}, + "+filter-update": {}, + "+filter-view-create": {}, + "+filter-view-update": {}, + "+pivot-create": {}, + "+pivot-update": {}, + "+range-sort": {}, + "+sparkline-create": {}, + "+sparkline-update": {}, +} diff --git a/shortcuts/sheets/flag_schemas_gen_test.go b/shortcuts/sheets/flag_schemas_gen_test.go new file mode 100644 index 000000000..418fd6b93 --- /dev/null +++ b/shortcuts/sheets/flag_schemas_gen_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "reflect" + "testing" +) + +// TestCommandsWithSchemaGen_MatchesJSON guards against drift between the +// codegen'd commandsWithSchema set (flag_schemas_gen.go) and the actual keys +// in data/flag-schemas.json — commandsWithFlagSchema() derives the set by +// parsing the embedded blob. This equivalence is what lets registration and +// the validate fast-path gate on the cheap set instead of parsing the 256KB +// schema at startup. +func TestCommandsWithSchemaGen_MatchesJSON(t *testing.T) { + t.Parallel() + fromJSON := commandsWithFlagSchema() + if !reflect.DeepEqual(fromJSON, commandsWithSchema) { + t.Error("commandsWithSchema differs from data/flag-schemas.json; regenerate flag_schemas_gen.go") + } +} diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go new file mode 100644 index 000000000..b31ed7b38 --- /dev/null +++ b/shortcuts/sheets/flag_view.go @@ -0,0 +1,321 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" +) + +// flagView is the read-only flag-accessor surface that every CLI-shape → +// MCP-tool-body translator (the *Input builders) depends on. It is satisfied +// as-is by *common.RuntimeContext (cobra-backed, used by standalone shortcut +// execution) and by mapFlagView (map-backed, used by +batch-update sub-ops). +// +// Routing both paths through the same interface lets a sub-op inside +// +batch-update reuse the exact same translator the standalone shortcut runs, +// so the generated MCP body is identical either way (enforced by the +// batch-vs-standalone contract test). +type flagView interface { + Str(name string) string + Int(name string) int + Float64(name string) float64 + Bool(name string) bool + StrArray(name string) []string + StrSlice(name string) []string + Changed(name string) bool + // Command returns the shortcut command this view feeds (e.g. + // "+pivot-create"). Used to look up the schema entry for + // schema-driven flag validation; both standalone and batch sub-op + // paths populate it so a sub-op gets validated against the same + // schema as the standalone shortcut. + Command() string +} + +// mapFlagView adapts a +batch-update sub-op input object (decoded JSON) to the +// flagView interface so the standalone *Input translators can consume it. +// +// Keys are matched leniently against the CLI flag name: a translator asking for +// "source-range" finds either "source-range" or "source_range" in the map (the +// reference docs use CLI flag names; users frequently send the underscore +// form). Composite values (arrays / objects for flags like cells / properties / +// sort-keys) are re-encoded to a JSON string on Str() so the downstream +// parseJSONFlag round-trips them exactly as it would a CLI string argument. +// +// To mirror the standalone cobra layer exactly, value reads fall back to the +// flag's declared default (seeded from flag-defs.json), while Changed() reflects +// only what the user actually provided. This split matters because some +// translators branch on Changed() (e.g. omit target_index unless --index was +// set) and others read defaulted values (e.g. row-count defaults to 200). +type mapFlagView struct { + raw map[string]interface{} // user-supplied sub-op input (drives Changed) + defaults map[string]interface{} // flag defaults (value fallback only) + command string // shortcut command (e.g. "+chart-create"); used by schema validator +} + +func (m mapFlagView) Command() string { return m.command } + +// newMapFlagViewForCommand wraps a sub-op input and seeds the value-fallback +// defaults declared for `command` in flag-defs.json, so an absent flag resolves +// to the same value the standalone cobra command would carry. +func newMapFlagViewForCommand(command string, input map[string]interface{}) mapFlagView { + fv := mapFlagView{raw: input, defaults: map[string]interface{}{}, command: command} + defs, err := loadFlagDefs() + if err != nil { + return fv + } + spec, ok := defs[command] + if !ok { + return fv + } + for _, df := range spec.Flags { + if df.Kind == "system" || df.Default == "" { + continue + } + fv.defaults[df.Name] = typedDefault(df) + } + return fv +} + +// typedDefault converts a flag's string default to the Go type matching its +// declared kind, so Int()/Bool()/Float64() see the right type. +func typedDefault(df flagDef) interface{} { + switch df.Type { + case "bool": + return df.Default == "true" + case "int": + var n int + fmt.Sscanf(df.Default, "%d", &n) + return n + case "float64": + var f float64 + fmt.Sscanf(df.Default, "%g", &f) + return f + default: + return df.Default + } +} + +// lookup resolves a flag name for a VALUE read: user input first (hyphen↔ +// underscore tolerant), then the seeded default. Returns the value and whether +// it was found in either source. +func (m mapFlagView) lookup(name string) (interface{}, bool) { + if v, ok := m.lookupRaw(name); ok { + return v, true + } + if m.defaults != nil { + if v, ok := m.defaults[name]; ok { + return v, true + } + } + return nil, false +} + +// lookupRaw resolves a flag name against the user-supplied input only, trying +// the exact key then the hyphen↔underscore variants. +func (m mapFlagView) lookupRaw(name string) (interface{}, bool) { + if v, ok := m.raw[name]; ok { + return v, true + } + if alt := strings.ReplaceAll(name, "-", "_"); alt != name { + if v, ok := m.raw[alt]; ok { + return v, true + } + } + if alt := strings.ReplaceAll(name, "_", "-"); alt != name { + if v, ok := m.raw[alt]; ok { + return v, true + } + } + return nil, false +} + +func (m mapFlagView) Str(name string) string { + v, ok := m.lookup(name) + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case bool, float64, int, int64: + b, _ := json.Marshal(t) + return string(b) + default: + // Arrays / objects (cells, properties, sort-keys, options, ...) are + // re-encoded so the translator's parseJSONFlag re-parses them. + b, err := json.Marshal(t) + if err != nil { + return "" + } + return string(b) + } +} + +func (m mapFlagView) Int(name string) int { + v, ok := m.lookup(name) + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case int64: + return int(t) + } + return 0 +} + +func (m mapFlagView) Float64(name string) float64 { + v, ok := m.lookup(name) + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case int64: + return float64(t) + } + return 0 +} + +func (m mapFlagView) Bool(name string) bool { + v, ok := m.lookup(name) + if !ok { + return false + } + b, _ := v.(bool) + return b +} + +func (m mapFlagView) StrArray(name string) []string { + return m.strSliceLike(name) +} + +func (m mapFlagView) StrSlice(name string) []string { + return m.strSliceLike(name) +} + +func (m mapFlagView) strSliceLike(name string) []string { + v, ok := m.lookup(name) + if !ok || v == nil { + return nil + } + switch t := v.(type) { + case []string: + return t + case []interface{}: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + case string: + // CSV / comma-separated (matches cobra StringSlice behavior). + if t == "" { + return nil + } + return strings.Split(t, ",") + } + return nil +} + +func (m mapFlagView) Changed(name string) bool { + _, ok := m.lookupRaw(name) + return ok +} + +// validateRawTypes rejects sub-op input fields whose JSON type contradicts the +// flag's declared type in flag-defs. +batch-update skips parse-time schema +// validation for `operations`, and Int/Float64/Bool silently fall back to +// the zero value on a type mismatch — so without this guard a wrong-typed scalar +// (e.g. "index":"abc" or "multiple":"true") would land as 0 / false instead of +// erroring, writing to the wrong place. Only numeric and boolean flags are +// checked; string and composite (array/object) flags stay permissive because +// Str() intentionally coerces them and the translator/schema validates shape. +// +// Returns a bare error; the +batch-update translator wraps it with the +// operations[i] () context. +func (m mapFlagView) validateRawTypes() error { + if len(m.raw) == 0 { + return nil + } + defs, err := loadFlagDefs() + if err != nil { + return nil //nolint:nilerr // fail-open: if flag-defs can't load, skip type validation rather than block the batch + } + spec, ok := defs[m.command] + if !ok { + return nil + } + declaredType := make(map[string]string, len(spec.Flags)) + for _, df := range spec.Flags { + declaredType[df.Name] = df.Type + } + for rawKey, val := range m.raw { + name := rawKey + typ, ok := declaredType[name] + if !ok { + // flag-defs use hyphen names; tolerate the underscore form users send. + name = strings.ReplaceAll(rawKey, "_", "-") + typ, ok = declaredType[name] + } + if !ok { + continue // unknown key — leave it for the translator / schema layer + } + switch typ { + case "int": + // Int(): float64 → int(t) truncates, so a non-integer number would + // be silently floored (1.9 → 1). Standalone cobra rejects it at + // parse time; reject here too to keep batch/standalone parity. + f, isNum := val.(float64) + if !isNum { + return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) + } + if math.Trunc(f) != f { + return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) + } + case "float64": + if _, isNum := val.(float64); !isNum { + return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) + } + case "bool": + if _, isBool := val.(bool); !isBool { + return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) + } + } + } + return nil +} + +// jsonTypeName names the JSON kind of a value decoded by encoding/json, for +// type-mismatch error messages. +func jsonTypeName(v interface{}) string { + switch v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case float64: + return "number" + case string: + return "string" + case []interface{}: + return "array" + case map[string]interface{}: + return "object" + default: + return fmt.Sprintf("%T", v) + } +} diff --git a/shortcuts/sheets/generate.go b/shortcuts/sheets/generate.go new file mode 100644 index 000000000..636650638 --- /dev/null +++ b/shortcuts/sheets/generate.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +// flag_defs_gen.go and flag_schemas_gen.go are generated from the canonical +// data/*.json spec artifacts (synced from sheet-skill-spec). After the sync +// script updates data/flag-defs.json or data/flag-schemas.json, regenerate +// the compiled Go with: +// +// go generate ./shortcuts/sheets/... +// +//go:generate go run ./internal/gen diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 6e8ba91df..3465484cb 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -1,51 +1,52 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT +// Package sheets contains lark-sheets shortcuts aligned with the +// sheet-skill-spec canonical layout. Each shortcut wraps a single +// sheet-ai-skills tool behind the One-OpenAPI endpoint +// (sheet_ai/v2/.../tools/invoke_{read,write}). package sheets import ( - "fmt" - "regexp" - "strconv" + "context" + "encoding/json" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) -var ( - singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`) - cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`) - cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`) - colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`) - rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`) - cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`) -) - -var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!") - -// getFirstSheetID queries the spreadsheet and returns the first sheet's ID. -func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) { - data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil) - if err != nil { +// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR +// pair shared by every sheets canonical shortcut and returns the resolved +// token. Network-free, safe to call from Validate and DryRun. +func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) { + if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil { return "", err } - sheets, _ := data["sheets"].([]interface{}) - if len(sheets) > 0 { - sheet, _ := sheets[0].(map[string]interface{}) - if id, ok := sheet["sheet_id"].(string); ok && id != "" { - return id, nil + if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" { + if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil { + return "", common.FlagErrorf("%v", err) } + return token, nil + } + + url := strings.TrimSpace(runtime.Str("url")) + token := extractSpreadsheetToken(url) + if token == "" || token == url { + return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/") + } + if err := validate.RejectControlChars(token, "url"); err != nil { + return "", common.FlagErrorf("%v", err) } - return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet") + return token, nil } -// extractSpreadsheetToken extracts spreadsheet token from URL. +// extractSpreadsheetToken pulls the token segment out of a /sheets/ +// or /spreadsheets/ URL. Returns the input unchanged when no known +// prefix is present (callers must check token != originalInput). func extractSpreadsheetToken(input string) string { input = strings.TrimSpace(input) - prefixes := []string{"/sheets/", "/spreadsheets/"} - for _, prefix := range prefixes { + for _, prefix := range []string{"/sheets/", "/spreadsheets/"} { if idx := strings.Index(input, prefix); idx >= 0 { token := input[idx+len(prefix):] if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 { @@ -57,183 +58,254 @@ func extractSpreadsheetToken(input string) string { return input } -func normalizeSheetRange(sheetID, input string) string { - input = normalizeSheetRangeSeparators(input) - if input == "" || strings.Contains(input, "!") || sheetID == "" { - return input - } - if looksLikeRelativeRange(input) { - return sheetID + "!" + input - } - return input -} - -func normalizePointRange(sheetID, input string) string { - input = normalizeSheetRange(sheetID, input) - if input == "" { - return input +// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and +// returns whichever was supplied. Network-free. +// +// Returned tuple: (sheetID, sheetName). Exactly one is non-empty — callers +// pass both through to the tool input; the server picks whichever fits. +func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName string, err error) { + if err := common.ExactlyOne(runtime, "sheet-id", "sheet-name"); err != nil { + return "", "", err + } + if id := strings.TrimSpace(runtime.Str("sheet-id")); id != "" { + if err := validate.RejectControlChars(id, "sheet-id"); err != nil { + return "", "", common.FlagErrorf("%v", err) + } + return id, "", nil } - rangeSheetID, subRange, ok := splitSheetRange(input) - if !ok || !singleCellRangePattern.MatchString(subRange) { - return input + name := strings.TrimSpace(runtime.Str("sheet-name")) + if err := validate.RejectControlChars(name, "sheet-name"); err != nil { + return "", "", common.FlagErrorf("%v", err) } - return rangeSheetID + "!" + subRange + ":" + subRange + return "", name, nil } -func normalizeWriteRange(sheetID, input string, values interface{}) string { - rows, cols := matrixDimensions(values) - input = normalizeSheetRangeSeparators(input) - if input == "" { - return buildRectRange(sheetID, "A1", rows, cols) - } - - input = normalizeSheetRange(sheetID, input) - rangeSheetID, subRange, ok := splitSheetRange(input) - if !ok { - return buildRectRange(input, "A1", rows, cols) - } - if singleCellRangePattern.MatchString(subRange) { - return buildRectRange(rangeSheetID, subRange, rows, cols) +// validateViaInput shrinks a shortcut's Validate to the minimal +// "token + ask the xxxInput builder if everything else is OK" pattern. +// The builder owns the sheet selector and shortcut-specific checks +// (--range required, --start >= 0, ...), so Validate no longer duplicates +// them — the same error fires whether the shortcut runs standalone or as a +// +batch-update sub-op. Use the inline form when the builder needs extra +// arguments (operation enum, withMergeType bool, ...). +func validateViaInput( + build func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error), +) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = build(runtime, token, sheetID, sheetName) + return err } - return input } -func validateSheetRangeInput(sheetID, input string) error { - input = normalizeSheetRangeSeparators(input) - if input == "" || strings.Contains(input, "!") || sheetID != "" { - return nil - } - if looksLikeRelativeRange(input) { - return common.FlagErrorf("--range %q requires --sheet-id or a ! prefix", input) +// requireSheetSelector is the flagView-agnostic counterpart of +// resolveSheetSelector: given the already-extracted (sheetID, sheetName) pair, +// it enforces the same XOR and control-char rules. +// +// Every batchable xxxInput builder calls this at the top so the same friendly +// error fires whether the shortcut runs standalone (Validate sees the error +// through the builder) or as a +batch-update sub-op (translator sees it +// directly, prefixed by operations[i]). Without this, batch sub-ops +// missing --sheet-id would slip through CLI validation and only fail on the +// server with an opaque "sheet undefined not found". +func requireSheetSelector(sheetID, sheetName string) error { + sheetID = strings.TrimSpace(sheetID) + sheetName = strings.TrimSpace(sheetName) + if sheetID == "" && sheetName == "" { + return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name") + } + if sheetID != "" && sheetName != "" { + return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive") + } + if sheetID != "" { + if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil { + return common.FlagErrorf("%v", err) + } + } else { + if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil { + return common.FlagErrorf("%v", err) + } } return nil } -// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are -// invalid for single-cell operations like write-image. Empty and single-cell -// values pass through. -func validateSingleCellRange(input string) error { - input = normalizeSheetRangeSeparators(input) - if input == "" { - return nil - } - // Extract the sub-range after the sheet ID prefix, if present. - subRange := input - if _, sr, ok := splitSheetRange(input); ok { - subRange = sr - } - if cellSpanRangePattern.MatchString(subRange) { - parts := strings.SplitN(subRange, ":", 2) - if strings.EqualFold(parts[0], parts[1]) { - return nil +// optionalSheetSelector is the "at most one" counterpart of +// requireSheetSelector: both empty is acceptable (the backend tool then +// decides what to do — e.g. manage_pivot_table_object auto-creates a new +// sub-sheet to host the pivot), and both set is rejected. Control-char +// validation still applies whenever a value is provided. +// +// Used by shortcuts whose backend tool treats sheet_id/sheet_name as the +// placement target rather than the operation context (currently only +// +pivot-create). Other shortcuts continue to use requireSheetSelector. +// +// idFlagName / nameFlagName parameterize the flag names quoted back in +// the mutex / control-char errors — +pivot-create exposes the placement +// selector as `--target-sheet-id` / `--target-sheet-name`, not the +// generic `--sheet-id` / `--sheet-name`, and the error wording must +// match what the user actually typed. +func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string) error { + sheetID = strings.TrimSpace(sheetID) + sheetName = strings.TrimSpace(sheetName) + if sheetID != "" && sheetName != "" { + return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName) + } + if sheetID != "" { + if err := validate.RejectControlChars(sheetID, idFlagName); err != nil { + return common.FlagErrorf("%v", err) + } + } else if sheetName != "" { + if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil { + return common.FlagErrorf("%v", err) } - return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input) } return nil } -func looksLikeRelativeRange(input string) bool { - input = normalizeSheetRangeSeparators(input) - if input == "" { - return false +// sheetSelectorForToolInput packs --sheet-id / --sheet-name into the tool +// input map, omitting empty fields. Use after resolveSheetSelector returns. +func sheetSelectorForToolInput(input map[string]interface{}, sheetID, sheetName string) { + if sheetID != "" { + input["sheet_id"] = sheetID + } + if sheetName != "" { + input["sheet_name"] = sheetName } - return singleCellRangePattern.MatchString(input) || - cellSpanRangePattern.MatchString(input) || - cellToColRangePattern.MatchString(input) || - colSpanRangePattern.MatchString(input) || - rowSpanRangePattern.MatchString(input) } -func splitSheetRange(input string) (sheetID, subRange string, ok bool) { - parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", false +// sheetSelectorPlaceholder returns a human-readable identifier for the +// selected sheet, suitable for DryRun output. Avoids leaking that --sheet-name +// would be resolved server-side at execute time. +func sheetSelectorPlaceholder(sheetID, sheetName string) string { + if sheetID != "" { + return sheetID } - return parts[0], parts[1], true + return "" } -func normalizeSheetRangeSeparators(input string) string { - input = strings.TrimSpace(input) - if input == "" { - return input - } - return sheetRangeSeparatorReplacer.Replace(input) +// parseJSONFlag parses a JSON string from a flag value. Returns nil when the +// flag is empty (caller decides if that's acceptable). Used by --data / +// --style / --options / --ranges / --colors and friends. +func parseJSONFlag(runtime flagView, name string) (interface{}, error) { + raw := strings.TrimSpace(runtime.Str(name)) + if raw == "" { + return nil, nil + } + var out interface{} + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err) + } + // Schema-driven flag validation at the user-input boundary. Skips + // --properties (validated at the input-builder tail after enhance + // hooks fill in flat-flag-derived fields) and any flag without an + // embedded schema entry. + if err := validateParsedJSONFlag(runtime, name, out); err != nil { + return nil, err + } + return out, nil } -func buildRectRange(sheetID, anchor string, rows, cols int) string { - if sheetID == "" { - return "" - } - if rows < 1 { - rows = 1 +// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}. +func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) { + v, err := parseJSONFlag(runtime, name) + if err != nil { + return nil, err } - if cols < 1 { - cols = 1 + if v == nil { + return nil, common.FlagErrorf("--%s is required", name) } - endCell, err := offsetCell(anchor, rows-1, cols-1) - if err != nil { - return sheetID + m, ok := v.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("--%s must be a JSON object", name) } - return sheetID + "!" + anchor + ":" + endCell + return m, nil } -func matrixDimensions(values interface{}) (rows, cols int) { - rowList, ok := values.([]interface{}) - if !ok || len(rowList) == 0 { - return 1, 1 +// requireJSONArray is parseJSONFlag + a type assertion to []interface{}. +func requireJSONArray(runtime flagView, name string) ([]interface{}, error) { + v, err := parseJSONFlag(runtime, name) + if err != nil { + return nil, err } - rows = len(rowList) - for _, row := range rowList { - if cells, ok := row.([]interface{}); ok && len(cells) > cols { - cols = len(cells) - } + if v == nil { + return nil, common.FlagErrorf("--%s is required", name) } - if cols == 0 { - cols = 1 + a, ok := v.([]interface{}) + if !ok { + return nil, common.FlagErrorf("--%s must be a JSON array", name) } - return rows, cols + return a, nil } -func offsetCell(cell string, rowOffset, colOffset int) (string, error) { - matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell)) - if len(matches) != 3 { - return "", fmt.Errorf("invalid cell reference: %s", cell) +// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─ + +// buildCellStyleFromFlags reads the 11 flat style flags and returns the +// cell_styles map expected by set_cell_range. Skips any flag the user +// didn't set so partial styles work. +func buildCellStyleFromFlags(runtime flagView) map[string]interface{} { + style := map[string]interface{}{} + if v := runtime.Str("background-color"); v != "" { + style["background_color"] = v } - colIndex := columnNameToIndex(matches[1]) - if colIndex < 1 { - return "", fmt.Errorf("invalid column: %s", matches[1]) + if v := runtime.Str("font-color"); v != "" { + style["font_color"] = v } - rowIndex, err := strconv.Atoi(matches[2]) - if err != nil { - return "", err + if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 { + style["font_size"] = runtime.Float64("font-size") } - return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil + if v := runtime.Str("font-style"); v != "" { + style["font_style"] = v + } + if v := runtime.Str("font-weight"); v != "" { + style["font_weight"] = v + } + if v := runtime.Str("font-line"); v != "" { + style["font_line"] = v + } + if v := runtime.Str("horizontal-alignment"); v != "" { + style["horizontal_alignment"] = v + } + if v := runtime.Str("vertical-alignment"); v != "" { + style["vertical_alignment"] = v + } + if v := runtime.Str("word-wrap"); v != "" { + style["word_wrap"] = v + } + if v := runtime.Str("number-format"); v != "" { + style["number_format"] = v + } + return style } -func columnNameToIndex(name string) int { - name = strings.ToUpper(strings.TrimSpace(name)) - if name == "" { - return 0 +// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/ +// left/right with style sub-objects). Returns nil when the flag is empty. +func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) { + if runtime.Str("border-styles") == "" { + return nil, nil } - index := 0 - for _, r := range name { - if r < 'A' || r > 'Z' { - return 0 - } - index = index*26 + int(r-'A'+1) + v, err := parseJSONFlag(runtime, "border-styles") + if err != nil { + return nil, err + } + m, ok := v.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("--border-styles must be a JSON object") } - return index + return m, nil } -func columnIndexToName(index int) string { - if index < 1 { - return "" +// requireAnyStyleFlag ensures at least one style-defining flag (style or +// border) is set — otherwise the request would do nothing. +func requireAnyStyleFlag(runtime flagView) error { + if len(buildCellStyleFromFlags(runtime)) > 0 { + return nil } - var out []byte - for index > 0 { - index-- - out = append([]byte{byte('A' + index%26)}, out...) - index /= 26 + if runtime.Str("border-styles") != "" { + return nil } - return string(out) + return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)") } diff --git a/shortcuts/sheets/helpers_test.go b/shortcuts/sheets/helpers_test.go new file mode 100644 index 000000000..15eb06166 --- /dev/null +++ b/shortcuts/sheets/helpers_test.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// testConfig returns a CliConfig wired with a stable user identity. Tests +// keep the AppID test-prefixed so logs / metrics can spot them. +func testConfig(t *testing.T) *core.CliConfig { + t.Helper() + replacer := strings.NewReplacer("/", "-", " ", "-") + suffix := replacer.Replace(strings.ToLower(t.Name())) + return &core.CliConfig{ + AppID: "test-sheets-" + suffix, + AppSecret: "secret-sheets-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: "ou_test_user", + } +} + +// newTestRig spins up a Factory wired with httpmock + the given shortcut +// mounted into a "sheets" parent command. Returns the cobra.Command ready +// to SetArgs / Execute, plus the stdout / stderr buffers and the registry. +func newTestRig(t *testing.T, sc common.Shortcut) (*cobra.Command, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + f, stdout, stderr, reg := cmdutil.TestFactory(t, testConfig(t)) + parent := &cobra.Command{Use: "sheets"} + sc.Mount(parent, f) + parent.SilenceErrors = true + parent.SilenceUsage = true + return parent, stdout, stderr, reg +} + +// runShortcut executes the shortcut with the given args and returns the +// captured stdout text. Mirrors the legacy package's parent.Execute() +// flow so test cases stay close to real CLI behavior. +func runShortcut(t *testing.T, sc common.Shortcut, args []string) (string, error) { + t.Helper() + parent, stdout, _, _ := newTestRig(t, sc) + parent.SetArgs(append([]string{sc.Command}, args...)) + err := parent.Execute() + return stdout.String(), err +} + +// runShortcutCapturingErr is runShortcut but also returns the stderr text +// so validation tests can inspect error envelopes. +func runShortcutCapturingErr(t *testing.T, sc common.Shortcut, args []string) (stdoutStr, stderrStr string, err error) { + t.Helper() + parent, stdout, stderr, _ := newTestRig(t, sc) + parent.SetArgs(append([]string{sc.Command}, args...)) + err = parent.Execute() + return stdout.String(), stderr.String(), err +} + +// runShortcutWithStubs is runShortcut + a slice of httpmock stubs. +// Stubs are registered before execute so the recorded API calls are +// served from the registry instead of touching the network. +func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs ...*httpmock.Stub) (string, error) { + t.Helper() + parent, stdout, _, reg := newTestRig(t, sc) + for _, s := range stubs { + reg.Register(s) + } + parent.SetArgs(append([]string{sc.Command}, args...)) + err := parent.Execute() + return stdout.String(), err +} + +// parseDryRunBody runs the shortcut in --dry-run and returns the first +// api call's body. The dry-run output format is: +// +// === Dry Run === +// { "api": [{...}], ... } +// +// Tests use this to assert the One-OpenAPI wire body is constructed +// correctly without exercising the real endpoint. +func parseDryRunBody(t *testing.T, sc common.Shortcut, args []string) map[string]interface{} { + t.Helper() + out, err := runShortcut(t, sc, append(args, "--dry-run")) + if err != nil { + t.Fatalf("dry-run failed: %v\noutput=%s", err, out) + } + return decodeDryRunFirstCall(t, out) +} + +// parseDryRunAPI returns the full list of `api` entries from a dry-run +// output — used by shortcuts that emit multiple calls (e.g. +// +workbook-export, +cells-set-image, +cells-batch-set-style). +func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface{} { + t.Helper() + out, err := runShortcut(t, sc, append(args, "--dry-run")) + if err != nil { + t.Fatalf("dry-run failed: %v\noutput=%s", err, out) + } + dryRun := decodeDryRunRaw(t, out) + calls, _ := dryRun["api"].([]interface{}) + return calls +} + +func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} { + t.Helper() + idx := strings.Index(out, "{") + if idx < 0 { + t.Fatalf("dry-run output has no JSON body:\n%s", out) + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(out[idx:]), &m); err != nil { + t.Fatalf("failed to parse dry-run JSON: %v\nraw=%s", err, out) + } + return m +} + +func decodeDryRunFirstCall(t *testing.T, out string) map[string]interface{} { + t.Helper() + dryRun := decodeDryRunRaw(t, out) + calls, ok := dryRun["api"].([]interface{}) + if !ok || len(calls) == 0 { + t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun) + } + call, _ := calls[0].(map[string]interface{}) + body, _ := call["body"].(map[string]interface{}) + if body == nil { + t.Fatalf("dry-run first call has no body: %#v", call) + } + return body +} + +// decodeToolInput parses the JSON-string `input` field embedded in a +// dry-run body whose tool_name matches `expected`. Returns the decoded +// tool input map so tests can assert on specific input fields. +func decodeToolInput(t *testing.T, body map[string]interface{}, expectedToolName string) map[string]interface{} { + t.Helper() + if got, _ := body["tool_name"].(string); got != expectedToolName { + t.Fatalf("tool_name = %q, want %q", got, expectedToolName) + } + rawInput, _ := body["input"].(string) + if rawInput == "" { + t.Fatalf("body.input is empty: %#v", body) + } + var input map[string]interface{} + if err := json.Unmarshal([]byte(rawInput), &input); err != nil { + t.Fatalf("failed to parse tool input JSON: %v\nraw=%s", err, rawInput) + } + return input +} + +// decodeEnvelopeData parses a successful envelope's data field — used by +// execute-path tests that go through the full callTool stack with stubs. +func decodeEnvelopeData(t *testing.T, out string) map[string]interface{} { + t.Helper() + var envelope map[string]interface{} + if err := json.Unmarshal([]byte(out), &envelope); err != nil { + t.Fatalf("failed to decode envelope: %v\nraw=%s", err, out) + } + if ok, _ := envelope["ok"].(bool); !ok { + t.Fatalf("envelope.ok=false: %#v", envelope) + } + data, _ := envelope["data"].(map[string]interface{}) + return data +} + +// toolOutputStub builds an httpmock stub for the One-OpenAPI invoke_read +// or invoke_write endpoint. `outputJSON` is the JSON string the tool +// returns in data.output. +func toolOutputStub(token, kind string, outputJSON string) *httpmock.Stub { + suffix := "invoke_read" + if kind == "write" { + suffix = "invoke_write" + } + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheet_ai/v2/spreadsheets/" + token + "/tools/" + suffix, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "output": outputJSON, + }, + }, + } +} + +// commonArgsURL is the typical --url and --sheet-id pair used by sheet- +// level tests. +const ( + testToken = "shtcnTestTOK" + testURL = "https://example.feishu.cn/sheets/shtcnTestTOK" + testSheetID = "shtSubA" + testSheetID2 = "shtSubB" +) diff --git a/shortcuts/sheets/internal/gen/main.go b/shortcuts/sheets/internal/gen/main.go new file mode 100644 index 000000000..4f481ca5e --- /dev/null +++ b/shortcuts/sheets/internal/gen/main.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Command gen regenerates flag_defs_gen.go and flag_schemas_gen.go from the +// data/*.json spec artifacts, so command startup pays no JSON unmarshal. +// +// Invoked via `go generate ./shortcuts/sheets/...` (see ../../generate.go). +// data/*.json stays the canonical source (synced from sheet-skill-spec); the +// *_gen.go files are committed, derived artifacts. CI should run go generate +// and fail on a dirty tree to keep them in lockstep. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "log" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +type flagDef struct { + Name string `json:"name"` + Kind string `json:"kind"` + Type string `json:"type"` + Required string `json:"required"` + Desc string `json:"desc"` + Default string `json:"default"` + Hidden bool `json:"hidden"` + Enum []string `json:"enum"` + Input []string `json:"input"` +} + +type commandDef struct { + Risk string `json:"risk"` + Flags []flagDef `json:"flags"` +} + +// sheetsDir resolves shortcuts/sheets from this generator's own location, so +// the tool works regardless of the caller's working directory. +func sheetsDir() string { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("gen: cannot resolve caller path") + } + // thisFile = /shortcuts/sheets/internal/gen/main.go + return filepath.Join(filepath.Dir(thisFile), "..", "..") +} + +func writeFormatted(path string, b *bytes.Buffer) { + out, err := format.Source(b.Bytes()) + if err != nil { + log.Fatalf("gen: format %s: %v", filepath.Base(path), err) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + log.Fatal(err) + } + fmt.Printf("wrote %s (%d bytes)\n", filepath.Base(path), len(out)) +} + +func main() { + dir := sheetsDir() + genFlagDefs(dir) + genFlagSchemas(dir) +} + +const flagDefsHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from data/flag-defs.json; DO NOT EDIT. + +package sheets + +// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's +// metadata for every shortcut, emitted as a Go literal so command startup +// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate +// with ` + "`go generate ./shortcuts/sheets/...`" + ` after data/flag-defs.json +// changes. +var flagDefs = map[string]commandDef{ +` + +func sliceLit(s []string) string { + parts := make([]string, len(s)) + for i, v := range s { + parts[i] = fmt.Sprintf("%q", v) + } + return "[]string{" + strings.Join(parts, ", ") + "}" +} + +func flagLit(f flagDef) string { + var p []string + if f.Name != "" { + p = append(p, fmt.Sprintf("Name: %q", f.Name)) + } + if f.Kind != "" { + p = append(p, fmt.Sprintf("Kind: %q", f.Kind)) + } + if f.Type != "" { + p = append(p, fmt.Sprintf("Type: %q", f.Type)) + } + if f.Required != "" { + p = append(p, fmt.Sprintf("Required: %q", f.Required)) + } + if f.Desc != "" { + p = append(p, fmt.Sprintf("Desc: %q", f.Desc)) + } + if f.Default != "" { + p = append(p, fmt.Sprintf("Default: %q", f.Default)) + } + if f.Hidden { + p = append(p, "Hidden: true") + } + if f.Enum != nil { + p = append(p, "Enum: "+sliceLit(f.Enum)) + } + if f.Input != nil { + p = append(p, "Input: "+sliceLit(f.Input)) + } + return "{" + strings.Join(p, ", ") + "}" +} + +func genFlagDefs(dir string) { + raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-defs.json")) + if err != nil { + log.Fatal(err) + } + var defs map[string]commandDef + if err := json.Unmarshal(raw, &defs); err != nil { + log.Fatal(err) + } + + keys := make([]string, 0, len(defs)) + for k := range defs { + keys = append(keys, k) + } + sort.Strings(keys) + + var b bytes.Buffer + b.WriteString(flagDefsHeader) + for _, k := range keys { + cd := defs[k] + fmt.Fprintf(&b, "%q: {\n", k) + if cd.Risk != "" { + fmt.Fprintf(&b, "Risk: %q,\n", cd.Risk) + } + if cd.Flags != nil { + b.WriteString("Flags: []flagDef{\n") + for _, f := range cd.Flags { + b.WriteString(flagLit(f)) + b.WriteString(",\n") + } + b.WriteString("},\n") + } + b.WriteString("},\n") + } + b.WriteString("}\n") + + writeFormatted(filepath.Join(dir, "flag_defs_gen.go"), &b) +} + +const flagSchemasHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Code generated from data/flag-schemas.json; DO NOT EDIT. + +package sheets + +// commandsWithSchema is the set of shortcut commands that have at least one +// introspectable composite flag in data/flag-schemas.json. Codegen'd so the +// registration loop (shortcuts.go) and the validate fast-path can gate on it +// without parsing the 256KB schema blob at startup (that parse used to run on +// every CLI invocation, sheets or not). The 256KB is now only unmarshaled +// on --print-schema or when validating a command that is in this set. Do not +// hand-edit; regenerate with ` + "`go generate ./shortcuts/sheets/...`" + `. +var commandsWithSchema = map[string]struct{}{ +` + +func genFlagSchemas(dir string) { + raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-schemas.json")) + if err != nil { + log.Fatal(err) + } + var doc struct { + Flags map[string]json.RawMessage `json:"flags"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + log.Fatal(err) + } + + keys := make([]string, 0, len(doc.Flags)) + for k := range doc.Flags { + keys = append(keys, k) + } + sort.Strings(keys) + + var b bytes.Buffer + b.WriteString(flagSchemasHeader) + for _, k := range keys { + fmt.Fprintf(&b, "%q: {},\n", k) + } + b.WriteString("}\n") + + writeFormatted(filepath.Join(dir, "flag_schemas_gen.go"), &b) +} diff --git a/shortcuts/sheets/lark_sheet_batch_update.go b/shortcuts/sheets/lark_sheet_batch_update.go new file mode 100644 index 000000000..696c40f23 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_batch_update.go @@ -0,0 +1,502 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_batch_update ────────────────────────────────────────── +// +// One tool (batch_update), four shortcuts: +// +// - +batch-update user supplies a CLI-shape operations array +// [{shortcut, input}, ...]; CLI translates to +// MCP shape {tool_name, input(+operation)} via +// batchOpDispatch before invoking the tool +// (high-risk-write — anything in batchOpDispatch +// can be inside) +// - +cells-batch-set-style fan a single style across many ranges +// - +dropdown-update install/replace the same dropdown across +// many ranges in one atomic batch +// - +dropdown-delete clear data_validation across many ranges +// (high-risk-write) +// +// The tool's contract (post-translation): +// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? } +// +// continue_on_error defaults to false (strict transaction): any failure +// rolls back the whole batch. CLI leaves the default in place for the +// three "fan-out" shortcuts since they're meant to be all-or-nothing; +// only +batch-update lets callers flip it via --continue-on-error. + +// BatchUpdate accepts a CLI-shape operations array (each item +// {shortcut, input}); on Validate / DryRun / Execute we translate each +// sub-op via batchOpDispatch (see batch_op_dispatch.go) into the MCP +// {tool_name, input(+operation)} form before calling the underlying +// batch_update tool. +var BatchUpdate = common.Shortcut{ + Service: "sheets", + Command: "+batch-update", + Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+batch-update"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + // Run the full translation in Validate so shape errors surface before + // DryRun / Execute. Translator is pure (no network), so re-running it + // in DryRun / Execute below is fine. + if _, err := batchUpdateInput(runtime, token); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := batchUpdateInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := batchUpdateInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.", + "Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).", + }, +} + +// batchUpdateInput translates the user-supplied CLI-shape operations array +// into the MCP batch_update payload. Returns FlagErrorf-typed errors on +// any per-op shape problem (translator validates each entry). +func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { + rawOps, err := parseBatchOperationsFlag(runtime) + if err != nil { + return nil, err + } + translated, err := translateBatchOperations(rawOps, token) + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operations": translated, + } + if runtime.Changed("continue-on-error") { + // An explicit --continue-on-error always wins over the envelope, so + // --continue-on-error=false keeps the strict-transaction default even + // when the --operations envelope carries continue_on_error:true. + if runtime.Bool("continue-on-error") { + input["continue_on_error"] = true + } + } else if envelope, _ := parseJSONFlag(runtime, "operations"); envelope != nil { + // No explicit flag: honor an inline override when --operations is an + // envelope object rather than a bare operations array. + if m, ok := envelope.(map[string]interface{}); ok { + if v, ok := m["continue_on_error"].(bool); ok && v { + input["continue_on_error"] = true + } + } + } + return input, nil +} + +// parseBatchOperationsFlag accepts --operations as either a JSON array (the +// operations list directly) or an envelope object { operations, continue_on_error } +// for back-compat with the legacy --data shape. Returns the operations array. +func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, error) { + v, err := parseJSONFlag(runtime, "operations") + if err != nil { + return nil, err + } + if v == nil { + return nil, common.FlagErrorf("--operations is required") + } + if arr, ok := v.([]interface{}); ok { + return arr, nil + } + if m, ok := v.(map[string]interface{}); ok { + if ops, ok := m["operations"].([]interface{}); ok { + return ops, nil + } + } + return nil, common.FlagErrorf("--operations must be a JSON array (or { operations: [...] } envelope)") +} + +// CellsBatchSetStyle stamps one style block across many sheet-prefixed +// ranges atomically. --ranges is a JSON array of sheet-prefixed A1 +// strings; the style is composed from the same flat flags as +// +cells-set-style. CLI fans each range into a separate set_cell_range +// op inside one batch_update. +var CellsBatchSetStyle = common.Shortcut{ + Service: "sheets", + Command: "+cells-batch-set-style", + Description: "Apply one style block to many sheet-prefixed ranges in one atomic batch.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-batch-set-style"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, err := validateDropdownRanges(runtime); err != nil { + return err + } + if err := requireAnyStyleFlag(runtime); err != nil { + return err + } + if _, err := borderStylesFromFlag(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := cellsBatchSetStyleInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := cellsBatchSetStyleInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return nil, err + } + cellStyle := buildCellStyleFromFlags(runtime) + borderStyles, err := borderStylesFromFlag(runtime) + if err != nil { + return nil, err + } + prototype := map[string]interface{}{} + if len(cellStyle) > 0 { + prototype["cell_styles"] = cellStyle + } + if borderStyles != nil { + prototype["border_styles"] = borderStyles + } + var ops []interface{} + for _, rng := range ranges { + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return nil, err + } + rows, cols, err := rangeDimensions(sub) + if err != nil { + return nil, common.FlagErrorf("range %q: %v", rng, err) + } + cells := fillCellsMatrix(rows, cols, prototype) + ops = append(ops, map[string]interface{}{ + "tool_name": "set_cell_range", + "input": map[string]interface{}{ + "excel_id": token, + "sheet_name": sheet, + "range": sub, + "cells": cells, + }, + }) + } + return map[string]interface{}{ + "excel_id": token, + "operations": ops, + }, nil +} + +// CellsBatchClear clears content / formats / both across many sheet-prefixed +// ranges in one atomic batch. --ranges is a JSON array of sheet-prefixed A1 +// strings; --scope reuses the +cells-clear vocabulary (content / formats / +// all). CLI fans each range into a separate clear_cell_range op inside one +// batch_update. high-risk-write because clear is irreversible. +var CellsBatchClear = common.Shortcut{ + Service: "sheets", + Command: "+cells-batch-clear", + Description: "Clear content/formats across many sheet-prefixed ranges in one atomic batch (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-batch-clear"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, err := validateDropdownRanges(runtime); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := cellsBatchClearInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := cellsBatchClearInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return annotateEmbeddedBlockClearErr(err) + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "high-risk-write — always preview with --dry-run; clear is not undoable.", + "Every --ranges item must carry a sheet prefix (e.g. \"Sheet1!A1:A10\"); all ranges are cleared with the same --scope.", + "Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.", + }, +} + +func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return nil, err + } + clearType := normalizeClearType(runtime.Str("scope")) + var ops []interface{} + for _, rng := range ranges { + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return nil, err + } + ops = append(ops, map[string]interface{}{ + "tool_name": "clear_cell_range", + "input": map[string]interface{}{ + "excel_id": token, + "sheet_name": sheet, + "range": sub, + "clear_type": clearType, + }, + }) + } + return map[string]interface{}{ + "excel_id": token, + "operations": ops, + }, nil +} + +// DropdownUpdate installs/replaces a single dropdown on many ranges in one +// atomic batch. Sheet ids come from the per-range sheet prefix. +var DropdownUpdate = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-update", + Description: "Install or replace one dropdown across many sheet-prefixed ranges atomically.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+dropdown-update"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, err := validateDropdownRanges(runtime); err != nil { + return err + } + if _, err := validateDropdownSourceOrOptions(runtime); err != nil { + return err + } + warnDropdownSourceRangeHighlight(runtime) + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := dropdownBatchInput(runtime, token, false) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := dropdownBatchInput(runtime, token, false) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// DropdownDelete clears data_validation across many ranges atomically. +var DropdownDelete = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-delete", + Description: "Clear dropdowns from many sheet-prefixed ranges atomically (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+dropdown-delete"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return err + } + if len(ranges) > 100 { + return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges)) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := dropdownBatchInput(runtime, token, true) + return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := dropdownBatchInput(runtime, token, true) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// dropdownBatchInput builds the batch_update payload for both +// +dropdown-update (clear=false, data_validation populated) and +// +dropdown-delete (clear=true, data_validation: null). +func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool) (map[string]interface{}, error) { + ranges, err := validateDropdownRanges(runtime) + if err != nil { + return nil, err + } + var prototype map[string]interface{} + if clear { + prototype = map[string]interface{}{"data_validation": nil} + } else { + validation, err := buildDropdownValidation(runtime) + if err != nil { + return nil, err + } + prototype = map[string]interface{}{"data_validation": validation} + } + var ops []interface{} + for _, rng := range ranges { + sheet, sub, err := splitSheetPrefixedRange(rng) + if err != nil { + return nil, err + } + rows, cols, err := rangeDimensions(sub) + if err != nil { + return nil, common.FlagErrorf("range %q: %v", rng, err) + } + cells := fillCellsMatrix(rows, cols, prototype) + ops = append(ops, map[string]interface{}{ + "tool_name": "set_cell_range", + "input": map[string]interface{}{ + "excel_id": token, + "sheet_name": sheet, + "range": sub, + "cells": cells, + }, + }) + } + return map[string]interface{}{ + "excel_id": token, + "operations": ops, + }, nil +} + +// ─── helpers resurrected from B3 (used here + future skills) ────────── + +// validateDropdownRanges parses --ranges, requires every entry to carry a +// sheet prefix, and returns the parsed list. +func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { + raw, err := requireJSONArray(runtime, "ranges") + if err != nil { + return nil, err + } + out := make([]string, 0, len(raw)) + for i, v := range raw { + s, ok := v.(string) + if !ok { + return nil, common.FlagErrorf("--ranges[%d] must be a string", i) + } + s = strings.TrimSpace(s) + if !strings.Contains(s, "!") { + return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s) + } + // Validate the sheet!range shape up front so malformed entries like + // "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail + // here at Validate instead of slipping through to DryRun/Execute. + _, sub, err := splitSheetPrefixedRange(s) + if err != nil { + return nil, common.FlagErrorf("--ranges[%d]: %v", i, err) + } + if _, _, err := rangeDimensions(sub); err != nil { + return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err) + } + out = append(out, s) + } + return out, nil +} + +// splitSheetPrefixedRange splits "sheet1!A2:A100" into ("sheet1", "A2:A100"). +func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) { + idx := strings.Index(rng, "!") + if idx <= 0 || idx == len(rng)-1 { + return "", "", common.FlagErrorf("range %q must use sheet!range form", rng) + } + return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil +} diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go new file mode 100644 index 000000000..e122c1355 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -0,0 +1,495 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestBatchUpdate_TranslatesShortcutToToolName verifies +batch-update +// translates each CLI-shape sub-op ({shortcut, input}) to the MCP-shape +// ({tool_name, input(+operation, +excel_id)}) before threading into +// the underlying batch_update tool. Covers continue_on_error too. +func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) { + t.Parallel() + + body := parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[ + {"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}, + {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"1","count":3}} + ]`, + "--continue-on-error", + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 2 { + t.Fatalf("operations length = %d, want 2", len(ops)) + } + if input["continue_on_error"] != true { + t.Errorf("continue_on_error = %v, want true", input["continue_on_error"]) + } + + // op[0]: +cells-set → set_cell_range, no operation field + op0 := ops[0].(map[string]interface{}) + if op0["tool_name"] != "set_cell_range" { + t.Errorf("op[0].tool_name = %v, want set_cell_range", op0["tool_name"]) + } + in0, _ := op0["input"].(map[string]interface{}) + if in0["excel_id"] == nil { + t.Errorf("op[0].input.excel_id missing (translator should inject)") + } + if _, has := in0["operation"]; has { + t.Errorf("op[0].input.operation present, +cells-set should not inject one: %#v", in0) + } + + // op[1]: +dim-insert → modify_sheet_structure + operation:"insert" + op1 := ops[1].(map[string]interface{}) + if op1["tool_name"] != "modify_sheet_structure" { + t.Errorf("op[1].tool_name = %v, want modify_sheet_structure", op1["tool_name"]) + } + in1, _ := op1["input"].(map[string]interface{}) + if in1["operation"] != "insert" { + t.Errorf("op[1].input.operation = %v, want \"insert\"", in1["operation"]) + } +} + +func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+cells-set","input":{}}]`, + }) + if err == nil { + t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr) + } +} + +// TestCellsBatchSetStyle_FansOutOps verifies multiple ranges produce one +// set_cell_range op each, sharing the same style flags. +func TestCellsBatchSetStyle_FansOutOps(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, CellsBatchSetStyle, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:B2","sheet1!D1:E2","sheet1!A5:A6"]`, + "--font-weight", "bold", + "--background-color", "#ffff00", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 3 { + t.Fatalf("operations length = %d, want 3 (one per range)", len(ops)) + } + for i, raw := range ops { + op, _ := raw.(map[string]interface{}) + if op["tool_name"] != "set_cell_range" { + t.Errorf("op[%d].tool_name = %v, want set_cell_range", i, op["tool_name"]) + } + params, _ := op["input"].(map[string]interface{}) + if params["sheet_name"] != "sheet1" { + t.Errorf("op[%d].sheet_name = %v, want sheet1", i, params["sheet_name"]) + } + cells, _ := params["cells"].([]interface{}) + row, _ := cells[0].([]interface{}) + cell, _ := row[0].(map[string]interface{}) + style, _ := cell["cell_styles"].(map[string]interface{}) + if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" { + t.Errorf("op[%d] cell_styles wrong: %#v", i, style) + } + } +} + +// TestCellsBatchClear_FansOutOps verifies multiple ranges produce one +// clear_cell_range op each, all sharing the same --scope-derived clear_type, +// with the sheet prefix split into sheet_name + bare range. +func TestCellsBatchClear_FansOutOps(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, CellsBatchClear, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:A10","sheet2!C1:D5","sheet1!F3"]`, + "--scope", "all", + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 3 { + t.Fatalf("operations length = %d, want 3 (one per range)", len(ops)) + } + wantSheet := []string{"sheet1", "sheet2", "sheet1"} + wantRange := []string{"A1:A10", "C1:D5", "F3"} + for i, raw := range ops { + op, _ := raw.(map[string]interface{}) + if op["tool_name"] != "clear_cell_range" { + t.Errorf("op[%d].tool_name = %v, want clear_cell_range", i, op["tool_name"]) + } + params, _ := op["input"].(map[string]interface{}) + if params["sheet_name"] != wantSheet[i] { + t.Errorf("op[%d].sheet_name = %v, want %s", i, params["sheet_name"], wantSheet[i]) + } + if params["range"] != wantRange[i] { + t.Errorf("op[%d].range = %v, want %s", i, params["range"], wantRange[i]) + } + if params["clear_type"] != "all" { + t.Errorf("op[%d].clear_type = %v, want all", i, params["clear_type"]) + } + } +} + +// TestCellsBatchClear_ScopeDefaultsToContents verifies the default --scope +// (content) maps to the tool's clear_type "contents" — identical to the +// standalone +cells-clear normalization. +func TestCellsBatchClear_ScopeDefaultsToContents(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, CellsBatchClear, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:B2"]`, + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 1 { + t.Fatalf("operations length = %d, want 1", len(ops)) + } + params, _ := ops[0].(map[string]interface{})["input"].(map[string]interface{}) + if params["clear_type"] != "contents" { + t.Errorf("clear_type = %v, want contents (default scope)", params["clear_type"]) + } +} + +// TestCellsBatchClear_Guards covers the sheet-prefix requirement and the +// high-risk-write confirmation gate. +func TestCellsBatchClear_Guards(t *testing.T) { + t.Parallel() + + // sheetless range → prefix guard (shared with the dropdown fan-outs). + stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{ + "--url", testURL, + "--ranges", `["A1:A10"]`, + "--yes", + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") { + t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err) + } + + // missing --yes → confirmation_required (high-risk-write). + stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:A10"]`, + }) + if err == nil { + t.Errorf("expected confirmation_required without --yes; stdout=%s stderr=%s", stdout, stderr) + } +} + +// TestDropdownUpdate_BatchPayload verifies the multi-range dropdown +// update fans out into a single batch_update with one set_cell_range +// op per range. Also covers --colors / --highlight -> highlight_colors +// / enable_highlight propagation through dropdownBatchInput. +func TestDropdownUpdate_BatchPayload(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`, + "--options", `["a","b","c"]`, + "--colors", `["#FFE699","#bff7d9","#ffb3b3"]`, + "--multiple", "--highlight", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 2 { + t.Fatalf("operations length = %d, want 2", len(ops)) + } + for i, raw := range ops { + op, _ := raw.(map[string]interface{}) + params, _ := op["input"].(map[string]interface{}) + cells, _ := params["cells"].([]interface{}) + if len(cells) != 4 { + t.Errorf("op[%d] cells rows = %d, want 4 (A2:A5 / C2:C5)", i, len(cells)) + } + row0, _ := cells[0].([]interface{}) + cell, _ := row0[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + if dv == nil || dv["type"] != "list" { + t.Errorf("op[%d] missing data_validation list: %#v", i, cell) + } + items, _ := dv["items"].([]interface{}) + if len(items) != 3 { + t.Errorf("op[%d] data_validation.items length = %d, want 3", i, len(items)) + } + if dv["support_multiple_values"] != true { + t.Errorf("op[%d] support_multiple_values = %v, want true", i, dv["support_multiple_values"]) + } + colors, _ := dv["highlight_colors"].([]interface{}) + if len(colors) != 3 { + t.Errorf("op[%d] highlight_colors length = %d, want 3", i, len(colors)) + } + if dv["enable_highlight"] != true { + t.Errorf("op[%d] enable_highlight = %v, want true", i, dv["enable_highlight"]) + } + } +} + +// TestDropdownDelete_BatchClearsValidation verifies delete sets +// data_validation: null on every cell. +func TestDropdownDelete_BatchClearsValidation(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownDelete, []string{ + "--url", testURL, + "--ranges", `["sheet1!A2:A4"]`, + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + if len(ops) != 1 { + t.Fatalf("operations length = %d, want 1", len(ops)) + } + op := ops[0].(map[string]interface{}) + params, _ := op["input"].(map[string]interface{}) + cells, _ := params["cells"].([]interface{}) + for i, raw := range cells { + row, _ := raw.([]interface{}) + cell, _ := row[0].(map[string]interface{}) + if _, present := cell["data_validation"]; !present { + t.Errorf("row %d: data_validation key missing", i) + continue + } + if cell["data_validation"] != nil { + t.Errorf("row %d: data_validation = %v, want null", i, cell["data_validation"]) + } + } +} + +func TestBatchUpdate_ValidationGuards(t *testing.T) { + t.Parallel() + + // dropdown-update with sheetless range + stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", `["A2:A5"]`, + "--options", `["a"]`, + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") { + t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err) + } + + // batch-update with empty operations + stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[]`, + "--yes", + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") { + t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err) + } + + // dropdown-update with non-array --options (object instead) → array guard + // (now via schema validator at parseJSONFlag time) + stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", `["sheet1!A1:A2"]`, + "--options", `{"not":"array"}`, + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) { + t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range +// validation: entries that merely contain "!" but are otherwise malformed (empty +// sheet, empty range, or an unparseable A1 ref) must fail at Validate rather than +// slip through to DryRun/Execute. Covers +dropdown-update / +dropdown-delete, +// which fan out over --ranges. +func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) { + t.Parallel() + cases := []struct { + name string + ranges string + want string + }{ + {"no sheet prefix at all", `["A1:A5"]`, "must include a sheet prefix"}, + {"empty sheet name", `["!A1:A5"]`, "must use sheet!range form"}, + {"empty range after prefix", `["Sheet1!"]`, "must use sheet!range form"}, + {"unparseable ref", `["Sheet1!bad"]`, "invalid cell ref"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{ + "--url", testURL, + "--ranges", tc.ranges, + "--options", `["a"]`, + "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) { + t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err) + } + }) + } +} + +// TestBatchUpdate_TranslatorRejects covers per-op shape errors caught by +// translateBatchOp: unknown shortcut, missing shortcut, banned (read / +// fan-out / legacy v2) shortcuts, hand-filled reserved keys, etc. +func TestBatchUpdate_TranslatorRejects(t *testing.T) { + t.Parallel() + cases := []struct { + name string + opsJSON string + wantMatch string + }{ + { + name: "missing shortcut field", + opsJSON: `[{"input":{"range":"A1"}}]`, + wantMatch: "'shortcut' field is required", + }, + { + name: "empty shortcut string", + opsJSON: `[{"shortcut":"","input":{}}]`, + wantMatch: "'shortcut' must be a non-empty string", + }, + { + name: "unknown shortcut", + opsJSON: `[{"shortcut":"+cells-set-magic","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "read op rejected", + opsJSON: `[{"shortcut":"+cells-get","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "nested batch-update rejected", + opsJSON: `[{"shortcut":"+batch-update","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "fan-out wrapper rejected", + opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "fan-out wrapper +cells-batch-clear rejected", + opsJSON: `[{"shortcut":"+cells-batch-clear","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "legacy v2 +dim-move rejected", + opsJSON: `[{"shortcut":"+dim-move","input":{}}]`, + wantMatch: "not allowed in +batch-update", + }, + { + name: "user filled operation manually", + opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","position":"1","count":1}}]`, + wantMatch: "do not pass input.operation", + }, + { + name: "user filled excel_id", + opsJSON: `[{"shortcut":"+cells-set","input":{"excel_id":"shtcnX","range":"A1"}}]`, + wantMatch: "do not pass input.excel_id", + }, + { + name: "user filled url", + opsJSON: `[{"shortcut":"+cells-set","input":{"url":"https://x.feishu.cn/sheets/sh","range":"A1"}}]`, + wantMatch: "do not pass input.url", + }, + { + name: "extra top-level key", + opsJSON: `[{"shortcut":"+cells-set","input":{"range":"A1"},"tool_name":"oops"}]`, + wantMatch: "unknown top-level key", + }, + { + name: "sub-op not an object", + opsJSON: `["not-an-object"]`, + wantMatch: "must be a JSON object", + }, + { + name: "input not an object", + opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`, + wantMatch: "'input' must be a JSON object", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", tc.opsJSON, + "--yes", + "--dry-run", + }) + if err == nil { + t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) { + t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err) + } + }) + } +} + +// 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). +func TestBatchUpdate_DimFreezeInjectsFreeze(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","dimension":"row","count":2}}]`, + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + ops, _ := input["operations"].([]interface{}) + op := ops[0].(map[string]interface{}) + if op["tool_name"] != "modify_sheet_structure" { + t.Errorf("tool_name = %v, want modify_sheet_structure", op["tool_name"]) + } + in, _ := op["input"].(map[string]interface{}) + if in["operation"] != "freeze" { + t.Errorf("operation = %v, want \"freeze\"", in["operation"]) + } +} + +// TestBatchUpdate_ResizeNoOperationField covers the resize_range dispatch: +// mapping has no operationField, so input.operation must NOT be injected. +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}}]`, + "--yes", + }) + input := decodeToolInput(t, body, "batch_update") + op := input["operations"].([]interface{})[0].(map[string]interface{}) + if op["tool_name"] != "resize_range" { + t.Errorf("tool_name = %v, want resize_range", op["tool_name"]) + } + in, _ := op["input"].(map[string]interface{}) + if _, has := in["operation"]; has { + t.Errorf("operation should NOT be injected for resize_range; got %#v", in) + } +} + +// TestSplitSheetPrefixedRange exercises the helper directly. +func TestSplitSheetPrefixedRange(t *testing.T) { + t.Parallel() + sheet, sub, err := splitSheetPrefixedRange("sheet1!A2:A100") + if err != nil || sheet != "sheet1" || sub != "A2:A100" { + t.Errorf("split = (%q,%q,%v), want (sheet1, A2:A100, nil)", sheet, sub, err) + } + if _, _, err := splitSheetPrefixedRange("A2:A100"); err == nil { + t.Error("expected error on missing prefix") + } + if _, _, err := splitSheetPrefixedRange("!A2"); err == nil { + t.Error("expected error on empty sheet name") + } + // Compile-time use of json import + _ = json.Marshal +} diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go new file mode 100644 index 000000000..221b9d03d --- /dev/null +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -0,0 +1,1043 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── object CRUD shortcuts ──────────────────────────────────────────── +// +// Six object skills (chart / pivot table / conditional format / filter / +// filter view / sparkline / float image) each expose a uniform create / +// update / delete trio backed by their manage__object tool. +// +// Shared shape: +// excel_id + sheet_id|sheet_name + operation + [_id] + [properties] +// +// CLI `--data` is passed through as the tool's `properties` payload as-is — +// callers shape it per the spec doc for each object (which is what makes +// the surface narrow even though everything funnels through one tool). +// +// Five of the seven objects share the factory below (newObjectCRUDShortcuts). +// pivot opts into allowEmptySheetSelectorOnCreate=true so the backend can +// auto-create a placement sub-sheet when neither --sheet-id nor --sheet-name +// is given; it also exposes optional --target-position on create. filter is +// special-cased further down (no separate id flag — filter_id is implicit +// per sheet — and --range is a first-class create flag, not buried in --data). + +// objectCRUDSpec describes a 3-shortcut create/update/delete cluster. +// idFlag / idField empty → no per-object id flag (only filter uses that +// today, and it has its own bespoke shortcuts further down). +type objectCRUDSpec struct { + commandPrefix string // e.g. "+chart" → +chart-create / -update / -delete + toolName string // e.g. "manage_chart_object" + idFlag string // e.g. "chart-id" + idField string // e.g. "chart_id" + // enhanceCreateInput / enhanceUpdateInput, when set, mutate the tool + // input after the standard fields are written. Used to inject + // shortcut-specific flat flags into the input (typically into the + // properties map). The callback is responsible for navigating to the + // right nesting level. + enhanceCreateInput func(rt flagView, input map[string]interface{}) + enhanceUpdateInput func(rt flagView, input map[string]interface{}) + // validateUpdateInput, when set, runs after enhanceUpdateInput to + // enforce *cross-field, update-only* constraints JSON Schema can't + // express (e.g. sparkline requires properties.sparklines[i] to + // carry sparkline_id on update — same schema is shared with create + // where the id is server-assigned). Type / enum / required / + // nested-shape checks are not handled here: they run automatically + // against data/flag-schemas.json in objectCreateInput / + // objectUpdateInput via validatePropertiesAgainstSchema. + validateUpdateInput func(input map[string]interface{}) error + // allowEmptySheetSelectorOnCreate, when true, makes the *create* + // shortcut accept empty --sheet-id / --sheet-name (backend then picks + // the placement target — e.g. manage_pivot_table_object auto-creates + // a sub-sheet to host the pivot). Both flags being set is still + // rejected. Update/delete continue to require an explicit selector. + // Today only pivotSpec opts in. + allowEmptySheetSelectorOnCreate bool + // createSheetIDFlag / createSheetNameFlag override the default + // `sheet-id` / `sheet-name` flag names on the *create* shortcut and + // its +batch-update sub-op. Used by pivot to expose + // `target-sheet-id` / `target-sheet-name` — the placement target, + // semantically distinct from the data-source sheet (which is encoded + // in --source as `'SheetName'!Range`). Empty = default names. + // Update/delete continue to use `sheet-id` / `sheet-name`. + createSheetIDFlag string + createSheetNameFlag string + // createTips, when set, populates the create shortcut's --help TIPS + // section. Used by pivot to make "omit --target-* → backend auto-creates + // a sub-sheet, zero overwrite" a hard, can't-miss note at the point of + // use (the most-stepped-on #REF! trap in real trajectories). + createTips []string + // createWarn, when set, is evaluated on the create shortcut's dry-run and + // execute paths; a non-empty return is surfaced as a `placement_warning` + // field in the output. Used by pivot to flag a likely source-data overwrite + // before it happens, without blocking the call. Local-only (no network), so + // it stays safe to call from dry-run. + createWarn func(rt flagView) string +} + +// sheetIDFlagOnCreate / sheetNameFlagOnCreate return the cobra flag name +// used to read the placement-sheet selector on this spec's create +// shortcut. Defaults to `sheet-id` / `sheet-name`. +func (s objectCRUDSpec) sheetIDFlagOnCreate() string { + if s.createSheetIDFlag != "" { + return s.createSheetIDFlag + } + return "sheet-id" +} + +func (s objectCRUDSpec) sheetNameFlagOnCreate() string { + if s.createSheetNameFlag != "" { + return s.createSheetNameFlag + } + return "sheet-name" +} + +func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { + flags := flagsFor(spec.commandPrefix + "-create") + return common.Shortcut{ + Service: "sheets", + Command: spec.commandPrefix + "-create", + Description: "Create a " + strings.TrimPrefix(spec.commandPrefix, "+") + " object via the manage_*_object tool.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Tips: spec.createTips, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate())) + sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate())) + _, err = objectCreateInput(runtime, token, sheetID, sheetName, spec) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate())) + sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate())) + input, _ := objectCreateInput(runtime, token, sheetID, sheetName, spec) + dr := invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) + if spec.createWarn != nil { + if w := spec.createWarn(runtime); w != "" { + dr = dr.Set("placement_warning", w) + } + } + return dr + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate())) + sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate())) + input, err := objectCreateInput(runtime, token, sheetID, sheetName, spec) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input) + if err != nil { + return err + } + if spec.createWarn != nil { + if w := spec.createWarn(runtime); w != "" { + if m, ok := out.(map[string]interface{}); ok { + m["placement_warning"] = w + } + } + } + runtime.Out(out, nil) + return nil + }, + } +} + +func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { + var err error + if spec.allowEmptySheetSelectorOnCreate { + err = optionalSheetSelector(sheetID, sheetName, spec.sheetIDFlagOnCreate(), spec.sheetNameFlagOnCreate()) + } else { + err = requireSheetSelector(sheetID, sheetName) + } + if err != nil { + return nil, err + } + props, err := requireJSONObject(runtime, "properties") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "create", + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if spec.enhanceCreateInput != nil { + spec.enhanceCreateInput(runtime, input) + } + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } + return input, nil +} + +func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { + flags := flagsFor(spec.commandPrefix + "-update") + return common.Shortcut{ + Service: "sheets", + Command: spec.commandPrefix + "-update", + Description: "Update an existing " + strings.TrimPrefix(spec.commandPrefix, "+") + " object (read-modify-write; consult --list first).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = objectUpdateInput(runtime, token, sheetID, sheetName, spec) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := objectUpdateInput(runtime, token, sheetID, sheetName, spec) + return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := objectUpdateInput(runtime, token, sheetID, sheetName, spec) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { + return nil, common.FlagErrorf("--%s is required", spec.idFlag) + } + props, err := requireJSONObject(runtime, "properties") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "update", + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if spec.idFlag != "" { + input[spec.idField] = strings.TrimSpace(runtime.Str(spec.idFlag)) + } + if spec.enhanceUpdateInput != nil { + spec.enhanceUpdateInput(runtime, input) + } + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } + if spec.validateUpdateInput != nil { + if err := spec.validateUpdateInput(input); err != nil { + return nil, err + } + } + return input, nil +} + +func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut { + flags := flagsFor(spec.commandPrefix + "-delete") + return common.Shortcut{ + Service: "sheets", + Command: spec.commandPrefix + "-delete", + Description: "Delete a " + strings.TrimPrefix(spec.commandPrefix, "+") + " object (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = objectDeleteInput(runtime, token, sheetID, sheetName, spec) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := objectDeleteInput(runtime, token, sheetID, sheetName, spec) + return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := objectDeleteInput(runtime, token, sheetID, sheetName, spec) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" { + return nil, common.FlagErrorf("--%s is required", spec.idFlag) + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "delete", + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if spec.idFlag != "" { + input[spec.idField] = strings.TrimSpace(runtime.Str(spec.idFlag)) + } + return input, nil +} + +// ─── per-object instantiations ──────────────────────────────────────── + +// chart +var chartSpec = objectCRUDSpec{ + commandPrefix: "+chart", + toolName: "manage_chart_object", + idFlag: "chart-id", + idField: "chart_id", +} +var ChartCreate = newObjectCreateShortcut(chartSpec) +var ChartUpdate = newObjectUpdateShortcut(chartSpec) +var ChartDelete = newObjectDeleteShortcut(chartSpec) + +// pivot — create exposes --target-position (top-level of the tool input) +// plus --source / --range hoisted from properties. --sheet-id / --sheet-name +// are the placement target (where the pivot table lands); the backend +// auto-creates a new sub-sheet when both are omitted, so create opts into +// allowEmptySheetSelectorOnCreate. +var pivotSpec = objectCRUDSpec{ + commandPrefix: "+pivot", + toolName: "manage_pivot_table_object", + idFlag: "pivot-table-id", + idField: "pivot_table_id", + allowEmptySheetSelectorOnCreate: true, + createSheetIDFlag: "target-sheet-id", + createSheetNameFlag: "target-sheet-name", + createTips: []string{ + "Placement: omit --target-sheet-id / --target-sheet-name and the backend auto-creates a fresh sub-sheet for the pivot — zero overwrite risk. This is the default and the recommended path.", + "Only pass --target-sheet-id/-name to land in an existing sheet; if that sheet holds the source data you MUST set --target-position (or --range) outside the data, else the pivot overwrites it and the anchor shows #REF!.", + "Removing a stray pivot is +pivot-delete (get its id from +pivot-list); +cells-clear / +cells-batch-clear only clear cell values/formats and cannot delete the pivot object.", + }, + createWarn: pivotPlacementWarn, + enhanceCreateInput: func(rt flagView, input map[string]interface{}) { + if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" { + input["target_position"] = v + } + props, _ := input["properties"].(map[string]interface{}) + if props == nil { + return + } + if v := strings.TrimSpace(rt.Str("source")); v != "" { + props["source"] = v + } + if v := strings.TrimSpace(rt.Str("range")); v != "" { + props["range"] = v + } + }, +} +var PivotCreate = newObjectCreateShortcut(pivotSpec) +var PivotUpdate = newObjectUpdateShortcut(pivotSpec) +var PivotDelete = newObjectDeleteShortcut(pivotSpec) + +// pivotPlacementWarn flags the one +pivot-create combination that silently +// overwrites data: an explicit placement sheet (--target-sheet-id/-name) with +// no offset (--target-position unset or A1, and no --range), so the pivot lands +// at A1 of an existing sheet. When that sheet is demonstrably the source-data +// sheet — target given by name, source carries a sheet prefix, names match — +// the warning is definite. When placement is by id (or the source has no +// prefix) the two can't be compared without a workbook lookup, which dry-run +// must avoid, so a conditional reminder is emitted instead. Returns "" when +// placement is safe (no target, or an offset was given). Advisory only: it is +// surfaced as placement_warning and never blocks the call. +func pivotPlacementWarn(rt flagView) string { + tgtID := strings.TrimSpace(rt.Str("target-sheet-id")) + tgtName := strings.TrimSpace(rt.Str("target-sheet-name")) + if tgtID == "" && tgtName == "" { + return "" // default path — backend auto-creates a sub-sheet, zero overwrite. + } + if pos := strings.TrimSpace(rt.Str("target-position")); pos != "" && pos != "A1" { + return "" // caller steered the pivot off A1. + } + if strings.TrimSpace(rt.Str("range")) != "" { + return "" // --range offset given. + } + srcSheet := sheetNameFromA1(rt.Str("source")) + if tgtName != "" && srcSheet != "" { + if strings.EqualFold(tgtName, srcSheet) { + return fmt.Sprintf("--target-sheet-name %q is the source-data sheet and no --target-position is set: "+ + "the pivot lands at A1 and overwrites the source (the anchor then shows #REF!). Set --target-position "+ + "to a blank cell outside the data, or omit --target-* to auto-create a sub-sheet.", tgtName) + } + return "" // distinct named sheet — safe. + } + return "a placement sheet is set without --target-position: if it is the source-data sheet, the pivot lands " + + "at A1 and overwrites the source (the anchor then shows #REF!). Set --target-position to a blank cell " + + "outside the data, or omit --target-* to auto-create a sub-sheet." +} + +// sheetNameFromA1 extracts the sheet name from a sheet-prefixed A1 reference, +// stripping the single quotes Lark wraps around names that contain spaces: +// "'Sheet 1'!A1:D100" → "Sheet 1", "Data!A1" → "Data". Returns "" when there +// is no sheet prefix. (splitSheetPrefixedRange keeps the quotes; this one drops +// them, which is what name comparison needs.) +func sheetNameFromA1(ref string) string { + ref = strings.TrimSpace(ref) + idx := strings.Index(ref, "!") + if idx <= 0 { + return "" + } + name := strings.TrimSpace(ref[:idx]) + if len(name) >= 2 && strings.HasPrefix(name, "'") && strings.HasSuffix(name, "'") { + name = name[1 : len(name)-1] + } + return name +} + +// conditional format — CLI surface uses --rule-id (short), wired to the +// tool's conditional_format_id on the wire. --rule-type and --ranges are +// hoisted out of properties (both required, set on every CRUD write). +// +// Wire shape matches manage_conditional_format_object.properties — the +// enum value lives at properties.rule_type (flat string, NOT nested under +// a `rule` object), and ranges is a sibling array. Earlier CLI builds +// wrote properties.rule.type which the server silently dropped — both +// path and enum vocabulary are now aligned with the server schema (see +// sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json line +// 3305-3324). +var condFormatEnhance = func(rt flagView, input map[string]interface{}) { + props, _ := input["properties"].(map[string]interface{}) + if props == nil { + return + } + if ruleType := strings.TrimSpace(rt.Str("rule-type")); ruleType != "" { + props["rule_type"] = ruleType + } + if rt.Str("ranges") != "" { + if arr, err := requireJSONArray(rt, "ranges"); err == nil { + props["ranges"] = arr + } + } +} + +var condFormatSpec = objectCRUDSpec{ + commandPrefix: "+cond-format", + toolName: "manage_conditional_format_object", + idFlag: "rule-id", + idField: "conditional_format_id", + enhanceCreateInput: condFormatEnhance, + enhanceUpdateInput: condFormatEnhance, +} +var CondFormatCreate = newObjectCreateShortcut(condFormatSpec) +var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec) +var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec) + +// sparkline — CLI uses --group-id (higher level) as the object selector. +// Two-layer ID model: --group-id picks the sparkline group; individual +// items inside properties.sparklines[] are addressed by sparkline_id. +// On update the server requires sparkline_id on every item (it's how +// the server maps each entry back to an existing sparkline); +// validateSparklineUpdateItems surfaces that requirement CLI-side with +// a pointer to +sparkline-list instead of letting the caller hit a +// server-side rejection that doesn't mention sparkline_id at all. +// +// (sparkline-delete is intentionally not pre-checked here: +// objectDeleteInput doesn't pass properties through, so the partial- +// delete branch — properties.sparklines: [{sparkline_id}] — silently +// degrades to whole-group delete today. Surfacing that gap is a +// separate fix; this validator stays scoped to update.) +func validateSparklineUpdateItems(input map[string]interface{}) error { + props, _ := input["properties"].(map[string]interface{}) + if props == nil { + return nil + } + raw, ok := props["sparklines"] + if !ok { + return nil // config-only update — fine + } + arr, ok := raw.([]interface{}) + if !ok { + return common.FlagErrorf("+sparkline-update properties.sparklines must be an array") + } + for i, item := range arr { + m, _ := item.(map[string]interface{}) + if m == nil { + return common.FlagErrorf("+sparkline-update properties.sparklines[%d] must be an object", i) + } + id, _ := m["sparkline_id"].(string) + if strings.TrimSpace(id) == "" { + return common.FlagErrorf("+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id ` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i) + } + } + return nil +} + +var sparklineSpec = objectCRUDSpec{ + commandPrefix: "+sparkline", + toolName: "manage_sparkline_object", + idFlag: "group-id", + idField: "group_id", + validateUpdateInput: validateSparklineUpdateItems, +} +var SparklineCreate = newObjectCreateShortcut(sparklineSpec) +var SparklineUpdate = newObjectUpdateShortcut(sparklineSpec) +var SparklineDelete = newObjectDeleteShortcut(sparklineSpec) + +// float image — fully hoisted to 10 flat flags. No --properties flag; +// the tool's properties is composed entirely from the position / size / +// offset / image_token / image_uri / z_index flat flags. + +// floatImageUploadPlaceholder is the stand-in image_token shown in +// Validate/DryRun for the --image (local upload) path, before the real +// file_token is known. Execute replaces it with the uploaded token. +const floatImageUploadPlaceholder = "" + +// floatImageName resolves the image name: explicit --image-name wins, +// otherwise fall back to the basename of a local --image path. +func floatImageName(runtime flagView) string { + if n := strings.TrimSpace(runtime.Str("image-name")); n != "" { + return n + } + if img := strings.TrimSpace(runtime.Str("image")); img != "" { + return filepath.Base(img) + } + return "" +} + +// floatImageProperties assembles the tool's properties object from the flat +// flags. The manage_float_image tool requires image_name, position and size on +// both create and update; the only difference is the image source: +// - create (requireImageSource=true): exactly one of --image / --image-token +// / --image-uri must be set. +// - update (requireImageSource=false): the image source is optional — omit +// all three to keep the current image; when given it stays mutually +// exclusive. Despite the "patch" framing, the tool still rejects an update +// missing image_name, position or size, and +float-image-list does not +// return image_name for the CLI to backfill, so the caller must supply the +// full core set. +// +// image_name, position and size are cobra-required on both create and update, +// so the standalone path is already gated by the flag layer; the explicit +// checks below are what enforces them on the +batch-update sub-op path, which +// has no cobra layer (mirrors the --float-image-id check in floatImageWriteInput). +// +// uploadedImageToken, when non-empty, is the file_token obtained by uploading a +// local --image (Execute only); in Validate/DryRun it is "" and a placeholder +// token stands in. +func floatImageProperties(runtime flagView, uploadedImageToken string, requireImageSource bool) (map[string]interface{}, error) { + img := strings.TrimSpace(runtime.Str("image")) + token := strings.TrimSpace(runtime.Str("image-token")) + uri := strings.TrimSpace(runtime.Str("image-uri")) + set := 0 + for _, v := range []string{img, token, uri} { + if v != "" { + set++ + } + } + if set == 0 && requireImageSource { + return nil, common.FlagErrorf("one of --image, --image-token, or --image-uri is required") + } + if set > 1 { + return nil, common.FlagErrorf("--image, --image-token, and --image-uri are mutually exclusive") + } + name := floatImageName(runtime) + if name == "" { + return nil, common.FlagErrorf("--image-name is required") + } + if !runtime.Changed("position-row") || !runtime.Changed("position-col") { + return nil, common.FlagErrorf("--position-row and --position-col are required") + } + if !runtime.Changed("size-width") || !runtime.Changed("size-height") { + return nil, common.FlagErrorf("--size-width and --size-height are required") + } + props := map[string]interface{}{ + "image_name": name, + "position": map[string]interface{}{ + "row": runtime.Int("position-row"), + "col": strings.TrimSpace(runtime.Str("position-col")), + }, + "size": map[string]interface{}{ + "width": runtime.Int("size-width"), + "height": runtime.Int("size-height"), + }, + } + switch { + case img != "": + // Local file: validate path safety here so --dry-run also rejects + // unsafe paths; Execute uploads it and passes the real token in. + if _, err := validate.SafeLocalFlagPath("--image", img); err != nil { + return nil, output.ErrValidation("%s", err) + } + if uploadedImageToken != "" { + props["image_token"] = uploadedImageToken + } else { + props["image_token"] = floatImageUploadPlaceholder + } + case token != "": + props["image_token"] = token + case uri != "": + props["image_uri"] = uri + } + if runtime.Changed("offset-row") || runtime.Changed("offset-col") { + offset := map[string]interface{}{} + if runtime.Changed("offset-row") { + offset["row_offset"] = runtime.Int("offset-row") + } + if runtime.Changed("offset-col") { + offset["col_offset"] = runtime.Int("offset-col") + } + props["offset"] = offset + } + if runtime.Changed("z-index") { + props["z_index"] = runtime.Int("z-index") + } + return props, nil +} + +func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isHighRisk bool) common.Shortcut { + risk := "write" + if isHighRisk { + risk = "high-risk-write" + } + flags := flagsFor(command) + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: description, + Risk: risk, + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + // uploadedImageToken="": Validate never uploads; floatImageProperties + // still validates the --image path and the source XOR. + _, err = floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, "") + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, "") + // With a local --image, Execute first uploads the file; surface that + // extra step in the preview (mirrors +cells-set-image's dry-run). + if img := strings.TrimSpace(runtime.Str("image")); img != "" { + manageBody, _ := buildToolBody("manage_float_image_object", input) + return common.NewDryRunAPI(). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc("upload local image to drive (parent_type=sheet_image)"). + Body(map[string]interface{}{ + "file_name": floatImageName(runtime), + "parent_type": "sheet_image", + "parent_node": token, + "size": "", + "file": "@" + img, + }). + POST(toolInvokePath(token, ToolKindWrite)). + Desc("create float image referencing the uploaded file_token"). + Body(manageBody) + } + return invokeToolDryRun(token, ToolKindWrite, "manage_float_image_object", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + // If a local --image was given, upload it first (parent_type= + // sheet_image) and embed the returned file_token; otherwise this + // returns "" and the token/uri flags are used as-is. + uploadedImageToken, err := uploadFloatImageIfLocal(runtime, token) + if err != nil { + return err + } + input, err := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, uploadedImageToken) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_float_image_object", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +// uploadFloatImageIfLocal uploads a local --image (when set) as a sheet_image +// and returns its file_token. Returns ("", nil) when --image is not set (the +// token/uri source flags are used instead, e.g. on +float-image-update which +// does not register --image). +func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) { + img := strings.TrimSpace(runtime.Str("image")) + if img == "" { + return "", nil + } + info, err := runtime.FileIO().Stat(img) + if err != nil { + return "", common.WrapInputStatError(err) + } + return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + FilePath: img, + FileName: floatImageName(runtime), + FileSize: info.Size(), + ParentType: "sheet_image", + ParentNode: &spreadsheetToken, + }) +} + +func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool, uploadedImageToken string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" { + return nil, common.FlagErrorf("--float-image-id is required") + } + props, err := floatImageProperties(runtime, uploadedImageToken, op == "create") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": op, + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if withIDFlag { + input["float_image_id"] = strings.TrimSpace(runtime.Str("float-image-id")) + } + return input, nil +} + +var FloatImageCreate = newFloatImageWriteShortcut( + "+float-image-create", + "Create a floating image (from a local --image path, or an existing --image-token / --image-uri).", + "create", false, false, +) +var FloatImageUpdate = newFloatImageWriteShortcut( + "+float-image-update", + "Update an existing floating image (target by --float-image-id; provide the full set of flat flags).", + "update", true, false, +) + +// FloatImageDelete uses the standard CRUD delete factory since it only +// needs --float-image-id + --yes. +var floatImageDeleteSpec = objectCRUDSpec{ + commandPrefix: "+float-image", + toolName: "manage_float_image_object", + idFlag: "float-image-id", + idField: "float_image_id", +} +var FloatImageDelete = newObjectDeleteShortcut(floatImageDeleteSpec) + +// filter view — cli_status: cli-only but the tool is in mcp-tools.json so +// it dispatches via the same One-OpenAPI endpoint as every other shortcut. +// --view-name and --range are hoisted out of properties (optional on both +// create and update; they always win over properties.{view_name, range}). +var filterViewEnhance = func(rt flagView, input map[string]interface{}) { + props, _ := input["properties"].(map[string]interface{}) + if props == nil { + return + } + if v := strings.TrimSpace(rt.Str("range")); v != "" { + props["range"] = v + } + if v := strings.TrimSpace(rt.Str("view-name")); v != "" { + props["view_name"] = v + } +} + +var filterViewSpec = objectCRUDSpec{ + commandPrefix: "+filter-view", + toolName: "manage_filter_view_object", + idFlag: "view-id", + idField: "view_id", + enhanceCreateInput: filterViewEnhance, + enhanceUpdateInput: filterViewEnhance, +} +var FilterViewCreate = newObjectCreateShortcut(filterViewSpec) +var FilterViewUpdate = newObjectUpdateShortcut(filterViewSpec) +var FilterViewDelete = newObjectDeleteShortcut(filterViewSpec) + +// ─── filter (sheet-scoped, no separate filter_id) ───────────────────── +// +// At most one filter per sheet, so filter_id is implicit (the tool treats +// filter_id and sheet_id as the same value). create requires --range +// (covering the header) and an optional --data with conditions; update +// patches conditions / range; delete drops the entire filter. + +// FilterCreate creates a sheet-level filter. --range covers the data +// (header inclusive). --data is optional — empty filter is valid. +var FilterCreate = common.Shortcut{ + Service: "sheets", + Command: "+filter-create", + Description: "Create a sheet-level filter (one per sheet).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+filter-create"), + Validate: validateViaInput(filterCreateInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := filterCreateInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := filterCreateInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } + props := map[string]interface{}{ + "range": strings.TrimSpace(runtime.Str("range")), + } + if runtime.Str("properties") != "" { + extra, err := requireJSONObject(runtime, "properties") + if err != nil { + return nil, err + } + for k, v := range extra { + if k == "range" { + continue // --range wins + } + props[k] = v + } + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "create", + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } + return input, nil +} + +// FilterUpdate patches the sheet-level filter. --properties carries the +// rules; --range is first-class and overrides any properties.range. +// filter_id is implicit (sheet-scoped). +var FilterUpdate = common.Shortcut{ + Service: "sheets", + Command: "+filter-update", + Description: "Update the sheet-level filter (overwrite rules + range).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+filter-update"), + Validate: validateViaInput(filterUpdateInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := filterUpdateInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := filterUpdateInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if sheetID == "" { + return nil, common.FlagErrorf("+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)") + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } + props, err := requireJSONObject(runtime, "properties") + if err != nil { + return nil, err + } + // --range wins over any properties.range + props["range"] = strings.TrimSpace(runtime.Str("range")) + input := map[string]interface{}{ + "excel_id": token, + "operation": "update", + "filter_id": sheetID, // server contract: filter_id === sheet_id for sheet-scoped filters + "properties": props, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } + return input, nil +} + +// FilterDelete drops the sheet-level filter entirely. high-risk-write. +var FilterDelete = common.Shortcut{ + Service: "sheets", + Command: "+filter-delete", + Description: "Remove the sheet-level filter (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+filter-delete"), + Validate: validateViaInput(filterDeleteInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := filterDeleteInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := filterDeleteInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// filterDeleteInput mirrors the standalone +filter-delete body for batch +// sub-op reuse. Server contract: filter_id === sheet_id, and update/delete +// must populate filter_id (per manage_filter_object schema). The CLI has no +// separate --filter-id flag because the value is fully derived from sheet_id; +// only --sheet-id is accepted (not --sheet-name, since there's no mid-call +// network lookup to resolve it). +func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if sheetID == "" { + return nil, common.FlagErrorf("+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)") + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "delete", + "filter_id": sheetID, // server contract: filter_id === sheet_id + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} diff --git a/shortcuts/sheets/lark_sheet_object_crud_test.go b/shortcuts/sheets/lark_sheet_object_crud_test.go new file mode 100644 index 000000000..728198298 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_object_crud_test.go @@ -0,0 +1,672 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestPivotPlacementWarn pins the advisory that fires only on the risky +// +pivot-create combination — an explicit placement sheet with no offset — +// and stays silent (or only conditionally reminds) everywhere else. +func TestPivotPlacementWarn(t *testing.T) { + t.Parallel() + tests := []struct { + name string + raw map[string]interface{} + want string // "" none | "definite" names the sheet | "conditional" generic reminder + }{ + {"no placement target → silent (default sub-sheet)", + map[string]interface{}{"source": "'Sheet1'!A1:D100"}, ""}, + {"target-position offset → silent", + map[string]interface{}{"target-sheet-name": "Sheet1", "source": "'Sheet1'!A1:D100", "target-position": "H1"}, ""}, + {"range offset → silent", + map[string]interface{}{"target-sheet-id": "sht_x", "range": "H1"}, ""}, + {"target name == source sheet, no offset → definite", + map[string]interface{}{"target-sheet-name": "Sheet1", "source": "'Sheet1'!A1:D100"}, "definite"}, + {"case-insensitive name match → definite", + map[string]interface{}{"target-sheet-name": "sheet1", "source": "'Sheet1'!A1:D100"}, "definite"}, + {"target name != source sheet → silent (distinct sheet is safe)", + map[string]interface{}{"target-sheet-name": "PivotOut", "source": "'Sheet1'!A1:D100"}, ""}, + {"target by id, no offset → conditional", + map[string]interface{}{"target-sheet-id": "sht_abc", "source": "'Sheet1'!A1:D100"}, "conditional"}, + {"target name but source lacks prefix → conditional", + map[string]interface{}{"target-sheet-name": "Sheet1", "source": "A1:D100"}, "conditional"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := pivotPlacementWarn(mapFlagView{raw: tc.raw}) + switch tc.want { + case "": + if got != "" { + t.Errorf("expected no warning, got %q", got) + } + case "definite": + if !strings.Contains(got, "--target-sheet-name") { + t.Errorf("expected definite warning citing --target-sheet-name, got %q", got) + } + case "conditional": + if !strings.Contains(got, "a placement sheet is set") { + t.Errorf("expected conditional reminder, got %q", got) + } + } + }) + } +} + +// TestSheetNameFromA1 covers the source-sheet extraction used by the placement +// warning: prefix detection, single-quote stripping, and the no-prefix case. +func TestSheetNameFromA1(t *testing.T) { + t.Parallel() + tests := []struct{ in, want string }{ + {"'Sheet1'!A1:D100", "Sheet1"}, + {"Data!A1", "Data"}, + {"'My Sheet'!A1:B2", "My Sheet"}, + {"A1:D100", ""}, + {"", ""}, + {" 'X'!A1 ", "X"}, + } + for _, tc := range tests { + if got := sheetNameFromA1(tc.in); got != tc.want { + t.Errorf("sheetNameFromA1(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestObjectCRUDShortcuts_DryRun walks the create / update / delete trio +// for each object skill. Together these cover all 21 CRUD shortcuts plus +// the per-object id flag renames (rule-id, group-id, view-id, etc.). +func TestObjectCRUDShortcuts_DryRun(t *testing.T) { + t.Parallel() + + type spec struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + } + + tests := []spec{ + // chart + { + name: "+chart-create", + sc: ChartCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}, + toolName: "manage_chart_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{ + "type": "line", + "position": map[string]interface{}{"row": float64(0), "col": "A"}, + "size": map[string]interface{}{"width": float64(400), "height": float64(300)}, + }, + }, + }, + { + name: "+chart-update", + sc: ChartUpdate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ", "--properties", `{"type":"bar","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}, + toolName: "manage_chart_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "chart_id": "chartXYZ", + "properties": map[string]interface{}{ + "type": "bar", + "position": map[string]interface{}{"row": float64(0), "col": "A"}, + "size": map[string]interface{}{"width": float64(400), "height": float64(300)}, + }, + }, + }, + // pivot — has extra create flags incl. required --source. + // --target-sheet-id is the placement target (where the pivot lands); + // the placement selector is renamed from the generic --sheet-id / + // --sheet-name to --target-sheet-id / --target-sheet-name to keep + // it semantically distinct from the data-source sheet (which is + // encoded inside --source as `'SheetName'!Range`). + // pivotSpec.allowEmptySheetSelectorOnCreate lets both target + // selectors be omitted so the backend auto-creates a sub-sheet — + // covered separately in the +pivot-create empty-selector / mutex + // tests below. + { + name: "+pivot-create with placement / source / range flags", + sc: PivotCreate, + args: []string{ + "--url", testURL, "--target-sheet-id", testSheetID, + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + "--range", "F1", + "--target-position", "B5", + }, + toolName: "manage_pivot_table_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "target_position": "B5", + "properties": map[string]interface{}{ + "rows": []interface{}{map[string]interface{}{"field": "A"}}, + "source": "Sheet1!A1:F1000", + "range": "F1", + }, + }, + }, + // +pivot-create accepts both target selectors empty — backend + // auto-creates a placement sub-sheet. + { + name: "+pivot-create empty --target-sheet-id / --target-sheet-name omits sheet from input", + sc: PivotCreate, + args: []string{ + "--url", testURL, + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + }, + toolName: "manage_pivot_table_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "create", + "properties": map[string]interface{}{ + "rows": []interface{}{map[string]interface{}{"field": "A"}}, + "source": "Sheet1!A1:F1000", + }, + }, + }, + { + name: "+pivot-delete", + sc: PivotDelete, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "ptA"}, + toolName: "manage_pivot_table_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "delete", + "pivot_table_id": "ptA", + }, + }, + // cond-format — --rule-id rename + --rule-type / --ranges hoist. + // rule_type lives at properties.rule_type (flat string), not nested + // under a `rule` object; enum vocabulary matches server schema + // (cellIs / duplicateValues / ... — see mcp-tools.json + // manage_conditional_format_object.properties.rule_type). + { + name: "+cond-format-update id rename + rule-type/ranges", + sc: CondFormatUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--rule-id", "ruleA", + "--properties", `{"attrs":[{"operator":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`, + "--rule-type", "cellIs", + "--ranges", `["A1:A100"]`, + }, + toolName: "manage_conditional_format_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "conditional_format_id": "ruleA", + "properties": map[string]interface{}{ + "rule_type": "cellIs", + "attrs": []interface{}{map[string]interface{}{"operator": "greaterThan", "value": "100"}}, + "style": map[string]interface{}{"back_color": "#FFD7D7"}, + "ranges": []interface{}{"A1:A100"}, + }, + }, + }, + // filter — special, no id flag + { + name: "+filter-create without --properties sends properties.range only", + sc: FilterCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[]}`}, + toolName: "manage_filter_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{ + "range": "A1:F1000", + "rules": []interface{}{}, + }, + }, + }, + { + name: "+filter-create with --properties merges rules", + sc: FilterCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"text","compare_type":"contains","values":["x"]}]}]}`}, + toolName: "manage_filter_object", + wantInput: map[string]interface{}{ + "properties": map[string]interface{}{ + "range": "A1:F1000", + "rules": []interface{}{map[string]interface{}{ + "column_index": "B", + "conditions": []interface{}{map[string]interface{}{ + "type": "text", + "compare_type": "contains", + "values": []interface{}{"x"}, + }}, + }}, + }, + }, + }, + { + // +filter-delete has no separate --filter-id flag because the + // server contract sets filter_id === sheet_id; the translator + // auto-injects filter_id from --sheet-id. update/delete fail + // hard when only --sheet-name is given (no mid-call lookup). + name: "+filter-delete (sheet-scoped, auto-injects filter_id=sheet_id)", + sc: FilterDelete, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "manage_filter_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "filter_id": testSheetID, + "operation": "delete", + }, + }, + { + // +filter-update auto-injects filter_id from sheet_id, hoists + // --range out of properties, and merges properties.rules. + name: "+filter-update auto-injects filter_id, hoists --range", + sc: FilterUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:F1000", + "--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"text","compare_type":"contains","values":["x"]}]}]}`, + }, + toolName: "manage_filter_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "filter_id": testSheetID, + "operation": "update", + "properties": map[string]interface{}{ + "range": "A1:F1000", + "rules": []interface{}{map[string]interface{}{ + "column_index": "B", + "conditions": []interface{}{map[string]interface{}{ + "type": "text", + "compare_type": "contains", + "values": []interface{}{"x"}, + }}, + }}, + }, + }, + }, + // filter-view CRUD (cli-only via callTool) + { + name: "+filter-view-create", + sc: FilterViewCreate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:Z100", "--properties", `{"view_name":"v1"}`}, + toolName: "manage_filter_view_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{"view_name": "v1", "range": "A1:Z100"}, + }, + }, + { + name: "+filter-view-update with --view-id", + sc: FilterViewUpdate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "vABC", "--properties", `{"view_name":"renamed"}`}, + toolName: "manage_filter_view_object", + wantInput: map[string]interface{}{ + "view_id": "vABC", + "operation": "update", + }, + }, + // sparkline --group-id + { + name: "+sparkline-update --group-id → group_id", + sc: SparklineUpdate, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", "--properties", `{"type":"line"}`}, + toolName: "manage_sparkline_object", + wantInput: map[string]interface{}{ + "group_id": "grpA", + "operation": "update", + "properties": map[string]interface{}{"type": "line"}, + }, + }, + { + // happy path for the new sparkline_id check: each + // properties.sparklines[i] carries sparkline_id, so the + // validator passes through cleanly. + name: "+sparkline-update properties.sparklines[] with sparkline_id passes", + sc: SparklineUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", + "--properties", `{"sparklines":[{"sparkline_id":"sl1","source":"Sheet1!A1:A10"}]}`, + }, + toolName: "manage_sparkline_object", + wantInput: map[string]interface{}{ + "group_id": "grpA", + "operation": "update", + "properties": map[string]interface{}{ + "sparklines": []interface{}{ + map[string]interface{}{"sparkline_id": "sl1", "source": "Sheet1!A1:A10"}, + }, + }, + }, + }, + // float-image — fully hoisted to flat flags + { + name: "+float-image-create with image-token + position/size", + sc: FloatImageCreate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--image-name", "logo.png", + "--image-token", "tok_xyz", + "--position-row", "2", "--position-col", "D", + "--size-width", "300", "--size-height", "200", + }, + toolName: "manage_float_image_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "create", + "properties": map[string]interface{}{ + "image_name": "logo.png", + "image_token": "tok_xyz", + "position": map[string]interface{}{"row": float64(2), "col": "D"}, + "size": map[string]interface{}{"width": float64(300), "height": float64(200)}, + }, + }, + }, + { + // patch mode: position + size with no image source. The image + // fields are omitted so the server keeps the current image; only + // image_name (server-mandated) and the changed geometry are sent. + // This is the shape that used to be rejected CLI-side. + name: "+float-image-update patch position+size, no image source", + sc: FloatImageUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--float-image-id", "imgABC", "--image-name", "logo.png", + "--position-row", "10", "--position-col", "I", + "--size-width", "90", "--size-height", "70", + }, + toolName: "manage_float_image_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "float_image_id": "imgABC", + "properties": map[string]interface{}{ + "image_name": "logo.png", + "position": map[string]interface{}{"row": float64(10), "col": "I"}, + "size": map[string]interface{}{"width": float64(90), "height": float64(70)}, + }, + }, + }, + { + // swap the image: an explicit --image-token rides alongside the + // mandatory core (image_name + position + size). + name: "+float-image-update swap image via image-token", + sc: FloatImageUpdate, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--float-image-id", "imgABC", + "--image-name", "new.png", "--image-token", "tok_new", + "--position-row", "2", "--position-col", "B", + "--size-width", "300", "--size-height", "200", + }, + toolName: "manage_float_image_object", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "update", + "float_image_id": "imgABC", + "properties": map[string]interface{}{ + "image_name": "new.png", + "image_token": "tok_new", + "position": map[string]interface{}{"row": float64(2), "col": "B"}, + "size": map[string]interface{}{"width": float64(300), "height": float64(200)}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestPivotCreate_SheetSelectorSemantics locks in the "at most one" +// semantics for +pivot-create (and only +pivot-create): both +// --target-sheet-id and --target-sheet-name may be omitted (backend +// auto-creates a placement sub-sheet), but passing both is rejected. +// +// Companion regression — TestObjectCreate_RequiresSheetSelector below — +// confirms every other *-create still rejects empty selector. +func TestPivotCreate_SheetSelectorSemantics(t *testing.T) { + t.Parallel() + + t.Run("both empty is accepted", func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, PivotCreate, []string{ + "--url", testURL, + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + }) + input := decodeToolInput(t, body, "manage_pivot_table_object") + if _, ok := input["sheet_id"]; ok { + t.Errorf("expected no sheet_id in input; got %v", input["sheet_id"]) + } + if _, ok := input["sheet_name"]; ok { + t.Errorf("expected no sheet_name in input; got %v", input["sheet_name"]) + } + }) + + t.Run("both set is rejected", func(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{ + "--url", testURL, + "--target-sheet-id", testSheetID, + "--target-sheet-name", "Sheet1", + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + }) + if err == nil { + t.Fatalf("expected CLI to reject both --target-sheet-id and --target-sheet-name set; stderr=%s", stderr) + } + combined := stderr + err.Error() + if !strings.Contains(combined, "mutually exclusive") { + t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err) + } + // 错误信息必须用真实的 flag 名(target-*),否则模型按消息提示去 + // 改 --sheet-id 还是错的。 + if !strings.Contains(combined, "--target-sheet-id") { + t.Errorf("expected error to quote --target-sheet-id flag name; got=%s|%v", stderr, err) + } + }) + + t.Run("only target-sheet-id is accepted", func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, PivotCreate, []string{ + "--url", testURL, + "--target-sheet-id", testSheetID, + "--properties", `{"rows":[{"field":"A"}]}`, + "--source", "Sheet1!A1:F1000", + }) + input := decodeToolInput(t, body, "manage_pivot_table_object") + if got, _ := input["sheet_id"].(string); got != testSheetID { + t.Errorf("sheet_id = %q, want %q", got, testSheetID) + } + }) +} + +// TestPivotCreate_SchemaValidates exercises the schema-driven +// validator wired into objectCreateInput. The pivot create schema +// doesn't constrain rows/columns/values to be present (the backend +// just creates an empty shell), but it does pin types and enums — +// confirm both kinds of misuse are surfaced as CLI-side errors and +// that schema-conformant input is accepted. +func TestPivotCreate_SchemaValidates(t *testing.T) { + t.Parallel() + + t.Run("rejects wrong type for rows", func(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{ + "--url", testURL, + "--properties", `{"rows":"not-an-array"}`, + "--source", "Sheet1!A1:F1000", + "--dry-run", + }) + if err == nil { + t.Fatalf("expected schema validator to reject rows=string; stderr=%s", stderr) + } + combined := stderr + err.Error() + if !strings.Contains(combined, "rows") || !strings.Contains(combined, "array") { + t.Errorf("expected error to mention rows/array; got=%s|%v", stderr, err) + } + }) + + t.Run("rejects out-of-enum summarize_by", func(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{ + "--url", testURL, + "--properties", `{"values":[{"field":"A","summarize_by":"BOGUS"}]}`, + "--source", "Sheet1!A1:F1000", + "--dry-run", + }) + if err == nil { + t.Fatalf("expected schema validator to reject summarize_by=BOGUS; stderr=%s", stderr) + } + if !strings.Contains(stderr+err.Error(), "summarize_by") { + t.Errorf("expected error to mention summarize_by; got=%s|%v", stderr, err) + } + }) + + t.Run("schema-conformant input is accepted", func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, PivotCreate, []string{ + "--url", testURL, + "--properties", `{"values":[{"field":"A","summarize_by":"sum"}]}`, + "--source", "Sheet1!A1:F1000", + }) + decodeToolInput(t, body, "manage_pivot_table_object") + }) +} + +// TestObjectCreate_RequiresSheetSelector regresses the non-pivot create +// shortcuts: pivot-create is the only one whose spec sets +// allowEmptySheetSelectorOnCreate=true. Every other *-create must still +// reject empty --sheet-id / --sheet-name (this is the guardrail that +// keeps the change minimally scoped). +func TestObjectCreate_RequiresSheetSelector(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc common.Shortcut + args []string // omit sheet selector flags on purpose + }{ + {"chart", ChartCreate, []string{"--url", testURL, "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}}, + {"cond-format", CondFormatCreate, []string{"--url", testURL, "--properties", `{"attrs":[]}`, "--rule-type", "cellIs", "--ranges", `["A1:A10"]`}}, + {"sparkline", SparklineCreate, []string{"--url", testURL, "--properties", `{"sparklines":[]}`}}, + {"filter-view", FilterViewCreate, []string{"--url", testURL, "--properties", `{}`, "--range", "A1:F10"}}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args) + if err == nil { + t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr) + } + combined := stderr + err.Error() + if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") { + t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err) + } + }) + } +} + +// TestSparklineUpdate_MissingSparklineID confirms the standalone-path +// pre-check fires: +sparkline-update with properties.sparklines[] but no +// per-item sparkline_id must fail CLI-side with a pointer to +// +sparkline-list, before any server call goes out. +func TestSparklineUpdate_MissingSparklineID(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, SparklineUpdate, []string{ + "--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", + "--properties", `{"sparklines":[{"source":"Sheet1!A1:A10"}]}`, + }) + if err == nil { + t.Fatalf("expected CLI to reject missing sparkline_id; stderr=%s", stderr) + } + combined := stderr + err.Error() + if !strings.Contains(combined, "missing sparkline_id") { + t.Errorf("expected error to mention missing sparkline_id; got=%s|%v", stderr, err) + } + if !strings.Contains(combined, "+sparkline-list") { + t.Errorf("expected error to point at +sparkline-list; got=%s|%v", stderr, err) + } +} + +// Note: +float-image-update's image_name / position / size are cobra-required +// (flag-defs.json), so the standalone path is gated by the flag layer — its +// "required flag(s) … not set" wording is framework-owned and intentionally not +// re-asserted here. The CLI-side enforcement that matters is on the +// +batch-update sub-op path (no cobra layer); that is covered by +// TestBatchOp_RejectsBadSubOpInput in batch_op_contract_test.go. + +// TestFloatImageCreate_RequiresImageSource guards the asymmetry with update: +// create still mandates one of --image / --image-token / --image-uri. +func TestFloatImageCreate_RequiresImageSource(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, FloatImageCreate, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--image-name", "x.png", + "--position-row", "0", "--position-col", "A", + "--size-width", "10", "--size-height", "10", + }) + if err == nil { + t.Fatalf("expected CLI to require an image source on create; stderr=%s", stderr) + } + if combined := stderr + err.Error(); !strings.Contains(combined, "one of --image, --image-token, or --image-uri is required") { + t.Errorf("expected error to require an image source; got=%s|%v", stderr, err) + } +} + +// TestObjectDelete_AllHighRisk asserts every delete shortcut blocks +// without --yes (framework-enforced). +func TestObjectDelete_AllHighRisk(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc common.Shortcut + args []string + }{ + {"chart", ChartDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "x"}}, + {"pivot", PivotDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "x"}}, + {"cond-format", CondFormatDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "x"}}, + {"filter", FilterDelete, []string{"--url", testURL, "--sheet-id", testSheetID}}, + {"filter-view", FilterViewDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "x"}}, + {"sparkline", SparklineDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "x"}}, + {"float-image", FloatImageDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "x"}}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args) + if err == nil { + t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") { + t.Errorf("expected confirmation gate; got=%s|%s|%v", stdout, stderr, err) + } + }) + } +} diff --git a/shortcuts/sheets/lark_sheet_object_list.go b/shortcuts/sheets/lark_sheet_object_list.go new file mode 100644 index 000000000..7c3b442ca --- /dev/null +++ b/shortcuts/sheets/lark_sheet_object_list.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── object list shortcuts ──────────────────────────────────────────── +// +// Seven object-collection skills each expose a single "list" read shortcut +// that lives next to their CRUD siblings (chart / pivot / cond-format / +// filter / filter-view / sparkline / float-image). All seven share the +// exact same shape — public sheet selector + optional -- filter — so +// they're declared via newObjectListShortcut. +// +// +filter-view-list is `cli_status: cli-only`, but the underlying tool +// get_filter_view_objects is in mcp-tools.json and dispatches through the +// same One-OpenAPI endpoint as everything else; no special path needed. + +// objectListSpec describes a single list-style read shortcut. +type objectListSpec struct { + command string // CLI command, e.g. "+chart-list" + description string // one-liner for --help + toolName string // MCP tool name, e.g. "get_chart_objects" + + // Optional id filter. Empty filterFlag → no filter flag exposed. + filterFlag string // CLI flag name (without leading --), e.g. "chart-id" + filterField string // tool input key, e.g. "chart_id" +} + +func newObjectListShortcut(spec objectListSpec) common.Shortcut { + flags := flagsFor(spec.command) + return common.Shortcut{ + Service: "sheets", + Command: spec.command, + Description: spec.description, + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func objectListInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectListSpec) map[string]interface{} { + input := map[string]interface{}{"excel_id": token} + sheetSelectorForToolInput(input, sheetID, sheetName) + if spec.filterFlag != "" { + if v := strings.TrimSpace(runtime.Str(spec.filterFlag)); v != "" { + input[spec.filterField] = v + } + } + return input +} + +// ─── shortcut declarations ──────────────────────────────────────────── + +// ChartList — list charts on a sheet (optionally filtered to one chart_id). +var ChartList = newObjectListShortcut(objectListSpec{ + command: "+chart-list", + description: "List charts on a sheet, optionally filtered to a single chart_id.", + toolName: "get_chart_objects", + filterFlag: "chart-id", + filterField: "chart_id", +}) + +// PivotList — list pivot tables on a sheet. +var PivotList = newObjectListShortcut(objectListSpec{ + command: "+pivot-list", + description: "List pivot tables on a sheet, optionally filtered to a single pivot_table_id.", + toolName: "get_pivot_table_objects", + filterFlag: "pivot-table-id", + filterField: "pivot_table_id", +}) + +// CondFormatList — list conditional format rules. CLI's --rule-id maps to +// the tool's conditional_format_id (CLI uses the shorter common term). +var CondFormatList = newObjectListShortcut(objectListSpec{ + command: "+cond-format-list", + description: "List conditional format rules on a sheet, optionally filtered to a single rule.", + toolName: "get_conditional_format_objects", + filterFlag: "rule-id", + filterField: "conditional_format_id", +}) + +// FilterList — list active sheet-level filters. No id filter because each +// sheet carries at most one filter. +var FilterList = newObjectListShortcut(objectListSpec{ + command: "+filter-list", + description: "List active sheet-level filters across the workbook (or one sheet).", + toolName: "get_filter_objects", +}) + +// FilterViewList — list filter views on a sheet. `cli-only` skill (not +// exposed as MCP tool catalog), but the tool itself is dispatched through +// the same One-OpenAPI endpoint. +var FilterViewList = newObjectListShortcut(objectListSpec{ + command: "+filter-view-list", + description: "List filter views on a sheet, optionally filtered to a single view_id.", + toolName: "get_filter_view_objects", + filterFlag: "view-id", + filterField: "view_id", +}) + +// SparklineList — list sparkline groups on a sheet. The tool also accepts +// a per-sparkline id (`sparkline_id`); CLI exposes the higher-level +// --group-id which is what callers usually care about. +var SparklineList = newObjectListShortcut(objectListSpec{ + command: "+sparkline-list", + description: "List sparkline groups on a sheet, optionally filtered by group_id.", + toolName: "get_sparkline_objects", + filterFlag: "group-id", + filterField: "group_id", +}) + +// FloatImageList — list floating images on a sheet (vs. embedded +// cell-images which live in cell metadata). +var FloatImageList = newObjectListShortcut(objectListSpec{ + command: "+float-image-list", + description: "List floating images on a sheet, optionally filtered to a single float_image_id.", + toolName: "get_float_image_objects", + filterFlag: "float-image-id", + filterField: "float_image_id", +}) diff --git a/shortcuts/sheets/lark_sheet_object_list_test.go b/shortcuts/sheets/lark_sheet_object_list_test.go new file mode 100644 index 000000000..25f82fd65 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_object_list_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestObjectListShortcuts_DryRun covers all 7 object-list shortcuts. +// Each spec asserts the tool name + that the optional filter flag maps +// to the right tool field (including the --rule-id → conditional_format_id +// rename). +func TestObjectListShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+chart-list no filter", + sc: ChartList, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "get_chart_objects", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + }, + }, + { + name: "+chart-list with filter", + sc: ChartList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ"}, + toolName: "get_chart_objects", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "chart_id": "chartXYZ", + }, + }, + { + name: "+pivot-list filter", + sc: PivotList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "ptA"}, + toolName: "get_pivot_table_objects", + wantInput: map[string]interface{}{ + "pivot_table_id": "ptA", + }, + }, + { + name: "+cond-format-list --rule-id → conditional_format_id", + sc: CondFormatList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA"}, + toolName: "get_conditional_format_objects", + wantInput: map[string]interface{}{ + "conditional_format_id": "ruleA", + }, + }, + { + name: "+filter-list (no filter flag) by sheet-name", + sc: FilterList, + args: []string{"--url", testURL, "--sheet-name", "Sheet1"}, + toolName: "get_filter_objects", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_name": "Sheet1", + }, + }, + { + name: "+filter-view-list cli-only via callTool", + sc: FilterViewList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "viewABC"}, + toolName: "get_filter_view_objects", + wantInput: map[string]interface{}{ + "view_id": "viewABC", + }, + }, + { + name: "+sparkline-list --group-id", + sc: SparklineList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA"}, + toolName: "get_sparkline_objects", + wantInput: map[string]interface{}{ + "group_id": "grpA", + }, + }, + { + name: "+float-image-list", + sc: FloatImageList, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "imgA"}, + toolName: "get_float_image_objects", + wantInput: map[string]interface{}{ + "float_image_id": "imgA", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go new file mode 100644 index 000000000..669e5eaf5 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -0,0 +1,665 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "errors" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_range_operations ────────────────────────────────────── +// +// Four tools, nine shortcuts: +// +// - clear_cell_range → +cells-clear (high-risk-write) +// - merge_cells → +cells-merge / +cells-unmerge +// - resize_range → +rows-resize / +cols-resize +// - transform_range → +range-move / +range-copy / +range-fill / +range-sort +// +// +rows-resize / +cols-resize are grouped under "工作表" for CLI discoverability +// even though the backing tool lives in this skill. + +// CellsClear wraps clear_cell_range. +// +// CLI's --scope vocabulary (content / formats / all) is normalized to the +// tool's clear_type vocabulary (contents / formats / all) — the spec's +// singular/plural mismatch is intentionally absorbed here. +var CellsClear = common.Shortcut{ + Service: "sheets", + Command: "+cells-clear", + Description: "Clear cell content, formats, or both within a range (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-clear"), + Validate: validateViaInput(cellsClearInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := cellsClearInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := cellsClearInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "clear_cell_range", input) + if err != nil { + return annotateEmbeddedBlockClearErr(err) + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "high-risk-write — always preview with --dry-run; clear is not undoable.", + "Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.", + }, +} + +func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } + input := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "clear_type": normalizeClearType(runtime.Str("scope")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +// normalizeClearType maps the CLI --scope vocabulary (content / formats / all) +// to the clear_cell_range tool's clear_type vocabulary (contents / formats / +// all). The content↔contents singular/plural mismatch is absorbed here so both +// +cells-clear and the +cells-batch-clear fan-out stay in lockstep. +func normalizeClearType(scope string) string { + switch scope { + case "formats", "all": + return scope + default: // "content" or unset + return "contents" + } +} + +// annotateEmbeddedBlockClearErr augments the backend's "embedded block" clear +// failure with the concrete fix. clear_cell_range only clears cell values / +// formats — it cannot delete an embedded object (pivot table / chart) that +// overlaps the range, which is what the backend's "can not find embedded block" +// actually means. Trajectories burned dozens of commands trying to recover a +// pivot-occupied A1 with cells-clear; point the agent at the object's own +// delete command instead. Non-matching errors pass through untouched. +func annotateEmbeddedBlockClearErr(err error) error { + var ee *output.ExitError + if !errors.As(err, &ee) || ee.Detail == nil { + return err + } + if !strings.Contains(strings.ToLower(ee.Detail.Message), "embedded block") { + return err + } + const hint = "the range overlaps an embedded object (pivot table / chart); " + + "cells-clear only clears cell values/formats and cannot delete it — " + + "delete the object with its own command (+pivot-delete / +chart-delete; find the id via +pivot-list / +chart-list)" + if ee.Detail.Hint == "" { + ee.Detail.Hint = hint + } else { + ee.Detail.Hint += "; " + hint + } + return ee +} + +// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the +// `operation` enum. --merge-type applies to merge only and maps to tool +// field merge_type (`all` / `rows` / `columns`). +var CellsMerge = newMergeShortcut( + "+cells-merge", "Merge cells in a range.", "merge", true, +) +var CellsUnmerge = newMergeShortcut( + "+cells-unmerge", "Unmerge cells in a range.", "unmerge", false, +) + +func newMergeShortcut(command, desc, op string, withMergeType bool) common.Shortcut { + flags := flagsFor(command) + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: desc, + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = mergeInput(runtime, token, sheetID, sheetName, op, withMergeType) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := mergeInput(runtime, token, sheetID, sheetName, op, withMergeType) + return invokeToolDryRun(token, ToolKindWrite, "merge_cells", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := mergeInput(runtime, token, sheetID, sheetName, op, withMergeType) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "merge_cells", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMergeType bool) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } + input := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "operation": op, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if withMergeType { + if mt := runtime.Str("merge-type"); mt != "" && mt != "all" { + input["merge_type"] = mt + } else { + input["merge_type"] = "all" + } + } + return input, nil +} + +// resize_range exposes two CLI shortcuts: +// +// +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. +// +// Wire shape: resize_height / resize_width carries { type, value? }, e.g. +// { "type": "pixel", "value": 30 } or { "type": "standard" }. + +// RowsResize wraps resize_range for row heights. --type auto enables +// auto-fit (rows only); --type pixel requires --size. +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\").", + 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 := resolveSpreadsheetToken(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 + }, +} + +// ColsResize wraps resize_range for column widths. Column widths do not +// support auto-fit — --type only accepts pixel / standard. +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).", + 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 { + 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 { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := resizeInput(runtime, token, sheetID, sheetName, "column") + 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 + }, +} + +// 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. +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) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = resizeInput(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" + } + return "" +} + +// commandForDimension returns the shortcut command name a given dimension +// belongs to; used in error messages so users see "+rows-resize" / "+cols-resize" +// instead of the internal "row" / "column" tag. +func commandForDimension(dimension string) string { + if dimension == "row" { + return "+rows-resize" + } + return "+cols-resize" +} + +// resizeInput builds the resize_range tool input. dimension is "row" / +// "column" (selected by the calling shortcut); --range must match that +// 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. +func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if !runtime.Changed("range") { + return nil, common.FlagErrorf("--range is required") + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + parsedDim, _, _, err := parseA1Range(rangeStr) + if err != nil { + return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err) + } + if parsedDim != dimension { + want := "row numbers (e.g. \"2:10\")" + if dimension == "column" { + want = "column letters (e.g. \"A:E\")" + } + return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want) + } + if !strings.Contains(rangeStr, ":") { + rangeStr = rangeStr + ":" + rangeStr + } + typ := strings.TrimSpace(runtime.Str("type")) + if typ == "" { + return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension)) + } + if dimension == "column" && typ == "auto" { + return nil, common.FlagErrorf("--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.FlagErrorf("--type pixel requires --size ") + } + if typ != "pixel" && hasSize { + return nil, common.FlagErrorf("--size is only valid with --type pixel") + } + 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 { + input["resize_width"] = sizeBlock + } + return input, nil +} + +// ─── transform_range (4 shortcuts) ──────────────────────────────────── +// +// move / copy take --source-range + --target-range (+ optional cross-sheet +// target). fill takes --source-range + --target-range + --series-type. sort +// takes --range + --sort-keys + --has-header. + +// RangeMove cuts data from --source-range and pastes at --target-range, +// optionally on another sheet. +var RangeMove = common.Shortcut{ + Service: "sheets", + Command: "+range-move", + Description: "Cut a range and paste it at a new location (optionally cross-sheet).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+range-move"), + Validate: validateRangeMoveOrCopy("move", false), + DryRun: transformDryRunFn("move", false, false), + Execute: transformExecuteFn("move", false, false), +} + +// RangeCopy duplicates a range to a new location with optional paste-type +// filter (values / formulas / formats / all). +var RangeCopy = common.Shortcut{ + Service: "sheets", + Command: "+range-copy", + Description: "Copy a range to a new location (--paste-type controls what is copied).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+range-copy"), + Validate: validateRangeMoveOrCopy("copy", true), + DryRun: transformDryRunFn("copy", true, false), + Execute: transformExecuteFn("copy", true, false), +} + +// RangeFill performs autofill from a template range into a target range. +// --series-type is a 5-value CLI vocabulary; the tool only distinguishes +// `copyCells` from `fillSeries`. The mapping is documented in +// fillSeriesToToolType. +var RangeFill = common.Shortcut{ + Service: "sheets", + Command: "+range-fill", + Description: "Autofill a target range from a source template (copy / linear / growth / date series).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+range-fill"), + Validate: validateViaInput(rangeFillInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := rangeFillInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "transform_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := rangeFillInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// RangeSort sorts rows within a range by one or more columns. +var RangeSort = common.Shortcut{ + Service: "sheets", + Command: "+range-sort", + Description: "Sort rows within a range by one or more columns.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+range-sort"), + Validate: validateViaInput(rangeSortInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := rangeSortInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "transform_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := rangeSortInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// ─── transform_range helpers ────────────────────────────────────────── + +// validateRangeMoveOrCopy wires the standalone Validate to transformMoveCopyInput +// so missing --source-range / --target-range fire the same friendly error on +// the batch sub-op path. +func validateRangeMoveOrCopy(op string, withPasteType bool) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType) + return err + } +} + +func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) *common.DryRunAPI { + return func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType) + return invokeToolDryRun(token, ToolKindWrite, "transform_range", input) + } +} + +func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + } +} + +func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op string, withPasteType bool) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("source-range")) == "" { + return nil, common.FlagErrorf("--source-range is required") + } + if strings.TrimSpace(runtime.Str("target-range")) == "" { + return nil, common.FlagErrorf("--target-range is required") + } + input := map[string]interface{}{ + "excel_id": token, + "operation": op, + "range": strings.TrimSpace(runtime.Str("source-range")), + "destination_range": strings.TrimSpace(runtime.Str("target-range")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if tgt := strings.TrimSpace(runtime.Str("target-sheet-id")); tgt != "" { + input["destination_sheet_id"] = tgt + } + if withPasteType { + if pt := runtime.Str("paste-type"); pt != "" && pt != "all" { + input["paste_type"] = pasteTypeToTool(pt) + } + } + return input, nil +} + +// pasteTypeToTool maps the CLI vocabulary (values / formulas / formats / all) +// to the tool's paste_type field (all / value_only / formula_only / format_only). +func pasteTypeToTool(pt string) string { + switch pt { + case "values": + return "value_only" + case "formulas": + return "formula_only" + case "formats": + return "format_only" + } + return "all" +} + +func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("source-range")) == "" { + return nil, common.FlagErrorf("--source-range is required") + } + if strings.TrimSpace(runtime.Str("target-range")) == "" { + return nil, common.FlagErrorf("--target-range is required") + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "fill", + "range": strings.TrimSpace(runtime.Str("source-range")), + "destination_range": strings.TrimSpace(runtime.Str("target-range")), + "fill_type": fillSeriesToToolType(runtime.Str("series-type")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +// fillSeriesToToolType maps the CLI series vocabulary to the tool's fill_type. +// The tool only distinguishes copy vs series; the CLI's series flavor (linear / +// growth / date / auto) all collapse to fillSeries — the actual progression is +// inferred by the server from the source cells. +func fillSeriesToToolType(seriesType string) string { + if seriesType == "copy" { + return "copyCells" + } + return "fillSeries" +} + +func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } + // requireJSONArray runs the embedded JSON Schema for --sort-keys + // via parseJSONFlag → validateParsedJSONFlag, so each item is + // already pinned to {column: string, ascending: bool} with the + // failing index reported. No per-item hand-written guard needed. + keys, err := requireJSONArray(runtime, "sort-keys") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "sort", + "range": strings.TrimSpace(runtime.Str("range")), + "sort_conditions": keys, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if runtime.Bool("has-header") { + input["has_header"] = true + } + return input, nil +} diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go new file mode 100644 index 000000000..e0f464709 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -0,0 +1,360 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestAnnotateEmbeddedBlockClearErr(t *testing.T) { + t.Parallel() + + t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) { + in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{ + Type: "api", + Message: `tool "clear_cell_range" failed: [500] can not find embedded block`, + }} + var ee *output.ExitError + if !errors.As(annotateEmbeddedBlockClearErr(in), &ee) || ee.Detail == nil { + t.Fatal("expected ExitError with detail") + } + if !strings.Contains(ee.Detail.Hint, "+pivot-delete") { + t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint) + } + }) + + t.Run("appends to existing hint", func(t *testing.T) { + in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{ + Message: "embedded block missing", Hint: "preexisting", + }} + out := annotateEmbeddedBlockClearErr(in).(*output.ExitError) + if !strings.HasPrefix(out.Detail.Hint, "preexisting; ") { + t.Errorf("existing hint should be preserved and appended, got %q", out.Detail.Hint) + } + }) + + t.Run("passes through unrelated ExitError untouched", func(t *testing.T) { + in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{Message: "some other failure"}} + out := annotateEmbeddedBlockClearErr(in).(*output.ExitError) + if out.Detail.Hint != "" { + t.Errorf("unrelated error should not gain a hint, got %q", out.Detail.Hint) + } + }) + + t.Run("passes through non-ExitError untouched", func(t *testing.T) { + in := errors.New("can not find embedded block") + if out := annotateEmbeddedBlockClearErr(in); out != in { + t.Error("plain (non-ExitError) error should be returned as-is") + } + }) +} + +func TestRangeOperationsShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+cells-clear scope=content → clear_type=contents", + sc: CellsClear, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C5", "--scope", "content"}, + toolName: "clear_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:C5", + "clear_type": "contents", + }, + }, + { + name: "+cells-clear scope=all passthrough", + sc: CellsClear, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C5", "--scope", "all"}, + toolName: "clear_cell_range", + wantInput: map[string]interface{}{ + "clear_type": "all", + }, + }, + { + name: "+cells-merge with merge-type", + sc: CellsMerge, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--merge-type", "rows"}, + toolName: "merge_cells", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:B2", + "operation": "merge", + "merge_type": "rows", + }, + }, + { + name: "+cells-unmerge (no merge-type flag)", + sc: CellsUnmerge, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, + toolName: "merge_cells", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:B2", + "operation": "unmerge", + }, + }, + { + name: "+rows-resize --range 1:5 pixel 200", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "1:5", + "resize_height": map[string]interface{}{ + "type": "pixel", + "value": float64(200), + }, + }, + }, + { + name: "+rows-resize single row \"1\" expands to \"1:1\"", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1", "--type", "auto"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "range": "1:1", + "resize_height": map[string]interface{}{"type": "auto"}, + }, + }, + { + name: "+cols-resize --range B:D standard", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "B:D", + "resize_width": map[string]interface{}{ + "type": "standard", + }, + }, + }, + { + name: "+cols-resize --range A:C pixel 120", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "range": "A:C", + "resize_width": map[string]interface{}{ + "type": "pixel", + "value": float64(120), + }, + }, + }, + { + name: "+cols-resize single column \"C\" expands to \"C:C\"", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C", "--type", "standard"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "range": "C:C", + "resize_width": map[string]interface{}{"type": "standard"}, + }, + }, + { + name: "+range-move cross-sheet", + sc: RangeMove, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "D1", "--target-sheet-id", testSheetID2}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "move", + "range": "A1:C5", + "destination_range": "D1", + "destination_sheet_id": testSheetID2, + }, + }, + { + name: "+range-copy paste-type values → value_only", + sc: RangeCopy, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "E1", "--paste-type", "values"}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "copy", + "range": "A1:C5", + "destination_range": "E1", + "paste_type": "value_only", + }, + }, + { + name: "+range-copy paste-type all → field omitted", + sc: RangeCopy, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "E1"}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "copy", + "range": "A1:C5", + "destination_range": "E1", + }, + }, + { + name: "+range-fill series=copy → copyCells", + sc: RangeFill, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:A3", "--target-range", "A4:A10", "--series-type", "copy"}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "fill", + "range": "A1:A3", + "destination_range": "A4:A10", + "fill_type": "copyCells", + }, + }, + { + name: "+range-fill series=linear → fillSeries", + sc: RangeFill, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:A3", "--target-range", "A4:A10", "--series-type", "linear"}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "fill_type": "fillSeries", + }, + }, + { + name: "+range-sort multi-key with header", + sc: RangeSort, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:E100", "--has-header", "--sort-keys", `[{"column":"B","ascending":true},{"column":"D","ascending":false}]`}, + toolName: "transform_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "operation": "sort", + "range": "A1:E100", + "has_header": true, + "sort_conditions": []interface{}{ + map[string]interface{}{"column": "B", "ascending": true}, + map[string]interface{}{"column": "D", "ascending": false}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestRangeSort_RejectsMalformedKeys verifies the schema-driven check +// that each --sort-keys entry has both `column` (string) and +// `ascending` (bool). The schema validator (loaded from +// data/flag-schemas.json) reports the offending JSON path; previously +// the CLI passed any JSON through and the server bounced with a terse +// "required property X missing" that didn't name the bad entry. +func TestRangeSort_RejectsMalformedKeys(t *testing.T) { + t.Parallel() + cases := []struct { + name string + keys string + want string + }{ + {"missing column", `[{"ascending":true}]`, `required property "column" is missing at [0]`}, + {"missing ascending", `[{"column":"B"}]`, `required property "ascending" is missing at [0]`}, + {"old vocab col/order", `[{"col":"B","order":"asc"}]`, `required property "column" is missing at [0]`}, + {"non-object item", `["B"]`, `[0]: expected type "object"`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:E10", "--sort-keys", c.keys, "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), c.want) { + t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err) + } + }) + } +} + +func TestResize_TypeAndSizeGuards(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc common.Shortcut + args []string + want string + }{ + { + name: "+rows-resize --type pixel without --size", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"}, + want: "--type pixel requires --size", + }, + { + name: "+rows-resize --type standard with --size", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"}, + want: "--size is only valid with --type pixel", + }, + { + 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" + }, + { + name: "+rows-resize given column range", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "standard"}, + want: "+rows-resize expects row numbers", + }, + { + name: "+cols-resize given row range", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard"}, + want: "+cols-resize expects column letters", + }, + { + name: "+rows-resize end < start", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--type", "standard"}, + want: "end position is before start", + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run")) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) + } +} diff --git a/shortcuts/sheets/lark_sheet_read_data.go b/shortcuts/sheets/lark_sheet_read_data.go new file mode 100644 index 000000000..40044c94a --- /dev/null +++ b/shortcuts/sheets/lark_sheet_read_data.go @@ -0,0 +1,523 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/csv" + "regexp" + "strconv" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_read_data ───────────────────────────────────────────── +// +// Wraps: +// - get_cell_ranges (powers +cells-get and +dropdown-get) +// - get_range_as_csv (powers +csv-get) +// +// The sandbox tool (export_sheet_to_sandbox) is Sheet-Tool-only and has no +// CLI surface here. + +// unboundedReadLimit is pinned into the tool's cell_limit / max_rows so that +// --max-chars is the single effective read cap. The underlying tools default +// those two to smaller values; without an explicit high value they could +// truncate before max_chars. The CLI no longer exposes --cell-limit / --max-rows +// (only --max-chars), so we pass this sentinel to neutralize the tool defaults. +// Large enough to never bind on any real sheet. +const unboundedReadLimit = 1_000_000_000 + +// CellsGet wraps get_cell_ranges: read multiple A1 ranges and return per-cell +// values, formulas, styles, and other metadata as requested via --include. +var CellsGet = common.Shortcut{ + Service: "sheets", + Command: "+cells-get", + Description: "Read one or more cell ranges with values, formulas, and optional styles / comments / data validation.", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-get"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "ranges": []string{strings.TrimSpace(runtime.Str("range"))}, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + applyIncludeToCellsGet(input, runtime.StrSlice("include")) + if runtime.Bool("skip-hidden") { + input["skip_hidden"] = true + } + // --cell-limit was removed from the CLI surface; --max-chars is the single + // read cap. Pin cell_limit very high so the tool's own default never binds + // before max_chars. + input["cell_limit"] = unboundedReadLimit + if n := runtime.Int("max-chars"); n > 0 { + input["max_chars"] = n + } + return input +} + +// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the +// tool's two coarse switches: +// +// - include_styles (bool) — toggled by "style" presence +// - value_render_option (enum) — "formula" → formula; otherwise omitted +// +// "value", "comment", and "data_validation" are always returned by the tool +// per the schema; they have no dedicated knob today but are accepted in +// --include for forward-compat with finer-grained server support. +func applyIncludeToCellsGet(input map[string]interface{}, include []string) { + if len(include) == 0 { + return + } + want := map[string]bool{} + for _, v := range include { + want[v] = true + } + if want["style"] { + input["include_styles"] = true + } else { + input["include_styles"] = false + } + if want["formula"] { + input["value_render_option"] = "formula" + } +} + +// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional +// [row=N] line prefix for easy row-number lookup. +var CsvGet = common.Shortcut{ + Service: "sheets", + Command: "+csv-get", + Description: "Read a range as CSV (with [row=N] line prefix by default).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+csv-get"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + switch { + case runtime.Bool("rows-json"): + // --rows-json reshapes the CSV response into structured rows + // ({row_number, values:{col→cell}}); see assembleRowsJSON. + out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range"))) + case !runtime.Bool("include-row-prefix"): + out = stripRowPrefixFromCsvOutput(out) + } + runtime.Out(out, nil) + return nil + }, +} + +func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token} + sheetSelectorForToolInput(input, sheetID, sheetName) + if r := strings.TrimSpace(runtime.Str("range")); r != "" { + input["range"] = r + } + if runtime.Bool("skip-hidden") { + input["skip_hidden"] = true + } + // --max-rows was removed from the CLI surface; --max-chars is the single + // read cap. Pin max_rows very high so the tool's own default never binds + // before max_chars. + input["max_rows"] = unboundedReadLimit + if n := runtime.Int("max-chars"); n > 0 { + input["max_chars"] = n + } + return input +} + +// stripRowPrefixFromCsvOutput removes "[row=N]" line prefixes from the tool's +// annotated_csv field. Operates client-side because the tool only emits the +// annotated form. +func stripRowPrefixFromCsvOutput(out interface{}) interface{} { + m, ok := out.(map[string]interface{}) + if !ok { + return out + } + csv, ok := m["annotated_csv"].(string) + if !ok { + return out + } + lines := strings.Split(csv, "\n") + for i, line := range lines { + if idx := strings.Index(line, "]"); idx >= 0 && strings.HasPrefix(line, "[row=") { + rest := line[idx+1:] + lines[i] = strings.TrimPrefix(rest, ",") + } + } + m["annotated_csv"] = strings.Join(lines, "\n") + return m +} + +// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that +// the tool prepends to the first physical line of each logical CSV record. +var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`) + +// assembleRowsJSON reshapes the tool's annotated_csv string into structured +// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand: +// +// { +// "range": "A1:K3380", +// "current_region": "...", // passthrough, if the tool returned it +// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}}, +// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...] +// } +// +// Every logical row is emitted, including the first — no row is assumed to be a +// header, since sheet data is not always tabular. Each cell is keyed by its +// column letter (from the tool's col_indices when present, else derived from the +// requested range's start column). On any parsing trouble it returns the +// original output unchanged. +func assembleRowsJSON(out interface{}, requestedRange string) interface{} { + m, ok := out.(map[string]interface{}) + if !ok { + return out + } + csvStr, ok := m["annotated_csv"].(string) + if !ok { + return out + } + + // Group physical lines into logical records by [row=N] boundaries; lines + // without a prefix are embedded-newline continuations of the current record. + type logicalRow struct { + num int + text string + } + var groups []logicalRow + for _, line := range strings.Split(csvStr, "\n") { + if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil { + n, _ := strconv.Atoi(mm[1]) + groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]}) + } else if len(groups) > 0 { + groups[len(groups)-1].text += "\n" + line + } + } + if len(groups) == 0 { + return out + } + + // Parse every logical row; widest row sets the column count. No row is + // singled out as a header — that would assume the data is tabular, which it + // often is not. The model reads row 1 like any other row and decides for + // itself whether it is a header. + parsed := make([][]string, len(groups)) + maxCols := 0 + for i, g := range groups { + parsed[i] = parseCSVRecord(g.text) + if len(parsed[i]) > maxCols { + maxCols = len(parsed[i]) + } + } + if maxCols == 0 { + return out + } + + // Column letters key each cell. Prefer the tool's col_indices (authoritative, + // length == col_count); otherwise derive from the requested range's start col. + letters := coerceStringSlice(m["col_indices"]) + if len(letters) < maxCols { + start := csvStartColIndex(requestedRange) + letters = make([]string, maxCols) + for j := 0; j < maxCols; j++ { + letters[j] = csvColLetter(start + j) + } + } + + rows := make([]map[string]interface{}, 0, len(groups)) + for i := range groups { + fields := parsed[i] + values := make(map[string]interface{}, len(letters)) + for j := range letters { + v := "" + if j < len(fields) { + v = fields[j] + } + values[letters[j]] = v + } + rows = append(rows, map[string]interface{}{ + "row_number": groups[i].num, + "values": values, + }) + } + + result := map[string]interface{}{} + for k, v := range m { + result[k] = v + } + result["range"] = requestedRange + result["rows"] = rows + + // Surface the backend's "数据没读全" signal structurally instead of leaving it + // buried in warning_message prose. The tool flags it when current_region (the + // true data extent) reaches past actual_range (what was actually read) — the + // single most important anti-under-read hint. Mirror that same comparison + // (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the + // model gets the real data range as a first-class field, never having to + // parse it out of prose. + if cr, _ := m["current_region"].(string); cr != "" { + ar, _ := m["actual_range"].(string) + regionEnd := a1EndRow(cr) + readEnd := a1EndRow(ar) + if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd { + result["data_not_fully_read"] = map[string]interface{}{ + "read_through_row": readEnd, + "data_extends_through_row": regionEnd, + "unread_rows": regionEnd - readEnd, + "reread_range": cr, + } + } + } + + // Drop the fields whose information rows-json fully carries elsewhere: + // - annotated_csv / row_indices / col_indices → reconstructed into + // columns + rows (with integer row_number), losslessly. + // - warning_message → its two halves are both handled: the static + // "[row=N] / col_indices[j]" parse nag is moot once those fields exist, + // and the dynamic "数据没读全" half is now the structured + // data_not_fully_read field above. (Confirmed against the backend's + // get-range-as-csv.ts — warning_message has no other content.) + delete(result, "annotated_csv") + delete(result, "row_indices") + delete(result, "col_indices") + delete(result, "warning_message") + return result +} + +// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51, +// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present. +func a1EndRow(rng string) int { + rng = strings.TrimSpace(rng) + if i := strings.LastIndex(rng, "!"); i >= 0 { + rng = rng[i+1:] + } + if i := strings.LastIndex(rng, ":"); i >= 0 { + rng = rng[i+1:] + } + var digits strings.Builder + for _, c := range rng { + if c >= '0' && c <= '9' { + digits.WriteRune(c) + } + } + if digits.Len() == 0 { + return 0 + } + n, _ := strconv.Atoi(digits.String()) + return n +} + +// parseCSVRecord parses a single logical CSV record (which may span multiple +// physical lines via quoted embedded newlines) into its fields. An empty record +// yields no fields; a malformed record falls back to a naive comma split so a +// stray quote never drops a whole row. +func parseCSVRecord(text string) []string { + if strings.TrimSpace(text) == "" { + return nil + } + r := csv.NewReader(strings.NewReader(text)) + r.FieldsPerRecord = -1 + fields, err := r.Read() + if err != nil { + return strings.Split(text, ",") + } + return fields +} + +// coerceStringSlice returns v as []string when it is a homogeneous []interface{} +// of strings (the shape of the tool's col_indices), else nil. +func coerceStringSlice(v interface{}) []string { + arr, ok := v.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, e := range arr { + s, ok := e.(string) + if !ok { + return nil + } + out = append(out, s) + } + return out +} + +// csvStartColIndex returns the 0-based column index of a range's start column, +// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0. +func csvStartColIndex(rng string) int { + rng = strings.TrimSpace(rng) + if i := strings.LastIndex(rng, "!"); i >= 0 { + rng = rng[i+1:] + } + var letters strings.Builder + for _, c := range rng { + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') { + letters.WriteRune(c) + continue + } + break + } + if letters.Len() == 0 { + return 0 + } + return csvColToIndex(letters.String()) +} + +// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10, +// "AA"→26). Non-letter input → -1. +func csvColToIndex(s string) int { + n := 0 + for _, c := range strings.ToUpper(s) { + if c < 'A' || c > 'Z' { + break + } + n = n*26 + int(c-'A'+1) + } + return n - 1 +} + +// csvColLetter converts a 0-based column index back to its letter (0→"A", +// 25→"Z", 26→"AA"). Negative input → "". +func csvColLetter(idx int) string { + if idx < 0 { + return "" + } + var b []byte + for idx >= 0 { + b = append([]byte{byte('A' + idx%26)}, b...) + idx = idx/26 - 1 + } + return string(b) +} + +// DropdownGet wraps get_cell_ranges scoped to data_validation: read the +// dropdown configuration on a range. Aligned with its sibling +cells-get +// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range +// is a bare A1 reference. The earlier "must include a sheet prefix" +// shape was the odd one out among the get_cell_ranges wrappers and made +// callers treat the prefix as either name or id; folding it into the +// canonical --sheet-id selector removes that ambiguity. +var DropdownGet = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-get", + Description: "Read the dropdown / data-validation configuration on a range.", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+dropdown-get"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return common.FlagErrorf("--range is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func dropdownGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "ranges": []string{strings.TrimSpace(runtime.Str("range"))}, + "include_styles": false, + "value_render_option": "formatted_value", + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} diff --git a/shortcuts/sheets/lark_sheet_read_data_test.go b/shortcuts/sheets/lark_sheet_read_data_test.go new file mode 100644 index 000000000..01e8001de --- /dev/null +++ b/shortcuts/sheets/lark_sheet_read_data_test.go @@ -0,0 +1,291 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestReadDataShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+cells-get single range + include=style,formula", + sc: CellsGet, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "style,formula"}, + toolName: "get_cell_ranges", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "ranges": []interface{}{"A1:B2"}, + "include_styles": true, + "value_render_option": "formula", + "cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap + }, + }, + { + // Canonical form: --sheet-id + bare --range. Aligned with + // +cells-get / +csv-get; before the e2e BUG-019 fix this + // shortcut was the odd one out (range-prefix required). + name: "+dropdown-get with --sheet-id", + sc: DropdownGet, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C2:C6"}, + toolName: "get_cell_ranges", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "ranges": []interface{}{"C2:C6"}, + "include_styles": false, + "value_render_option": "formatted_value", + }, + }, + { + name: "+dropdown-get with --sheet-name", + sc: DropdownGet, + args: []string{"--url", testURL, "--sheet-name", "Sheet1", "--range", "C2:C6"}, + toolName: "get_cell_ranges", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_name": "Sheet1", + "ranges": []interface{}{"C2:C6"}, + "include_styles": false, + "value_render_option": "formatted_value", + }, + }, + { + // --rows-json is post-processing on +csv-get's response; it must + // NOT leak into the get_range_as_csv input. + name: "+csv-get --rows-json builds the same input (flag is post-process)", + sc: CsvGet, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"}, + toolName: "get_range_as_csv", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:C10", + "max_rows": float64(unboundedReadLimit), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestDropdownGet_RequiresSheetSelector locks the +cells-get-style +// selector contract: at least one of --sheet-id / --sheet-name must be +// supplied. Before BUG-019 fix this shortcut required a "Sheet!A1" +// prefix inside --range instead; the canonical selector pair is what +// every other get_cell_ranges wrapper uses. +func TestDropdownGet_RequiresSheetSelector(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DropdownGet, []string{ + "--url", testURL, "--range", "A2:A100", "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, "sheet-id") && !strings.Contains(combined, "sheet-name") { + t.Errorf("expected --sheet-id/--sheet-name guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestReadData_RequiresRange covers the trim-based --range guard on the +// single-range readers (--range "" slips past cobra's MarkFlagRequired but +// must still be rejected by Validate). +func TestReadData_RequiresRange(t *testing.T) { + t.Parallel() + cases := []struct { + name string + sc common.Shortcut + }{ + {"+cells-get", CellsGet}, + {"+csv-get", CsvGet}, + {"+dropdown-get", DropdownGet}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{ + "--url", testURL, "--sheet-id", testSheetID, "--range", " ", "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "--range is required") { + t.Errorf("expected --range guard; got=%s|%s|%v", stdout, stderr, err) + } + }) + } +} + +// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping +// directly (white-box). +func TestInfoTypeFromInclude(t *testing.T) { + t.Parallel() + // Caller (sheetInfoInput) skips infoTypeFromInclude when len(include)==0, + // so the helper only ever sees non-empty input. + cases := []struct { + include []string + want string + }{ + {[]string{"row_heights"}, "row_heights_column_widths"}, + {[]string{"row_heights", "col_widths"}, "row_heights_column_widths"}, + {[]string{"hidden_rows", "hidden_cols"}, "hidden_infos"}, + {[]string{"groups"}, "group_infos"}, + {[]string{"merges"}, "merged_cells_infos"}, + {[]string{"row_heights", "merges"}, "all"}, // mixed + {[]string{"frozen"}, "all"}, // frozen alone falls back to all + {[]string{"unknown"}, "all"}, // unknown → all + } + for _, c := range cases { + if got := infoTypeFromInclude(c.include); got != c.want { + t.Errorf("infoTypeFromInclude(%v) = %q, want %q", c.include, got, c.want) + } + } +} + +// TestCsvGet_StripRowPrefix verifies the client-side post-process for +// --include-row-prefix=false. +func TestCsvGet_StripRowPrefix(t *testing.T) { + t.Parallel() + in := map[string]interface{}{ + "annotated_csv": "[row=1] a,b,c\n[row=2] d,e,f", + "other": "untouched", + } + out := stripRowPrefixFromCsvOutput(in).(map[string]interface{}) + csv := out["annotated_csv"].(string) + if csv != " a,b,c\n d,e,f" { + t.Errorf("annotated_csv = %q, want stripped prefix", csv) + } + if out["other"] != "untouched" { + t.Errorf("other field corrupted: %v", out["other"]) + } +} + +// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row +// emitted (no header singled out), integer row_number, column-letter keyed +// values, embedded newlines inside quoted fields, and current_region passthrough. +func TestAssembleRowsJSON(t *testing.T) { + t.Parallel() + in := map[string]interface{}{ + "annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3", + "current_region": "A1:C3", + "col_indices": []interface{}{"A", "B", "C"}, + "row_indices": []interface{}{1, 2, 3}, + "warning_message": "①定位行号…②定位列字母…", + } + out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{}) + if !ok { + t.Fatalf("assembleRowsJSON did not return a map") + } + + // Fields whose info rows-json carries elsewhere are dropped (annotated_csv / + // indices → rows; warning_message → moot static nag + structured + // data_not_fully_read). Unrelated metadata like current_region is preserved. + if _, exists := out["annotated_csv"]; exists { + t.Errorf("annotated_csv should be dropped") + } + if _, exists := out["col_indices"]; exists { + t.Errorf("col_indices should be dropped") + } + if _, exists := out["warning_message"]; exists { + t.Errorf("warning_message should be dropped in rows-json mode") + } + if _, exists := out["columns"]; exists { + t.Errorf("columns field should not exist (no header assumption)") + } + if out["current_region"] != "A1:C3" { + t.Errorf("current_region passthrough lost: %v", out["current_region"]) + } + + rows, _ := out["rows"].([]map[string]interface{}) + if len(rows) != 3 { + t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows) + } + // Row 1 is emitted as a normal row, not consumed as a header. + if rows[0]["row_number"].(int) != 1 { + t.Errorf("first row_number = %v, want 1", rows[0]["row_number"]) + } + if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" { + t.Errorf("row 1 values wrong: %+v", v) + } + // Row 2 keeps its embedded newline inside a single cell. + v1 := rows[1]["values"].(map[string]interface{}) + if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" { + t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1]) + } +} + +// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the +// range start when the tool omits col_indices (e.g. a C-anchored read). +func TestAssembleRowsJSON_DerivedLetters(t *testing.T) { + t.Parallel() + in := map[string]interface{}{ + "annotated_csv": "[row=5] h1,h2\n[row=6] a,b", + } + out := assembleRowsJSON(in, "C5:D6").(map[string]interface{}) + rows := out["rows"].([]map[string]interface{}) + if len(rows) != 2 { + t.Fatalf("want 2 rows, got %d", len(rows)) + } + if rows[0]["row_number"].(int) != 5 { + t.Errorf("first row_number = %v, want 5", rows[0]["row_number"]) + } + if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" { + t.Errorf("derived-letter values wrong: %+v", v) + } + if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" { + t.Errorf("row 6 values wrong: %+v", v) + } +} + +// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint: +// when current_region extends past actual_range, rows-json surfaces the true data +// range as a first-class field (mirroring the backend's prose warning). +func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) { + t.Parallel() + // Read only A1:D2, but the data region reaches D4 → 2 rows unread. + in := map[string]interface{}{ + "annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三", + "actual_range": "A1:D2", + "current_region": "A1:D4", + } + out := assembleRowsJSON(in, "A1:D2").(map[string]interface{}) + hint, ok := out["data_not_fully_read"].(map[string]interface{}) + if !ok { + t.Fatalf("data_not_fully_read missing; out=%+v", out) + } + if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 || + hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" { + t.Errorf("data_not_fully_read wrong: %+v", hint) + } + + // Fully-read case: no hint emitted. + in2 := map[string]interface{}{ + "annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三", + "actual_range": "A1:D2", + "current_region": "A1:D2", + } + out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{}) + if _, exists := out2["data_not_fully_read"]; exists { + t.Errorf("data_not_fully_read should be absent when fully read") + } +} diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go new file mode 100644 index 000000000..6e0b8ecb3 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_search_replace ──────────────────────────────────────── +// +// Wraps search_data (read) and replace_data (write). Both tools take an +// `options` sub-object; the CLI flattens its common booleans +// (--match-case / --match-entire-cell / --regex / --include-formulas) into +// independent flags per the铁律. + +// CellsSearch wraps search_data: find cell coordinates matching --find, +// with optional case / regex / whole-cell / formula-text controls. +var CellsSearch = common.Shortcut{ + Service: "sheets", + Command: "+cells-search", + Description: "Find cells matching --find in a spreadsheet (case / regex / whole-cell / formula-text controls).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-search"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("find")) == "" { + return common.FlagErrorf("--find is required") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func searchInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "search_term": runtime.Str("find"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if r := strings.TrimSpace(runtime.Str("range")); r != "" { + input["range"] = r + } + if runtime.Changed("offset") && runtime.Int("offset") > 0 { + input["offset"] = runtime.Int("offset") + } + if opts := searchReplaceOptions(runtime); len(opts) > 0 { + input["options"] = opts + } + if n := runtime.Int("max-matches"); n > 0 { + input["max_matches"] = n + } + return input +} + +// searchReplaceOptions packs the four shared boolean flags into the tool's +// `options` sub-object. Empty result → caller should omit the field. +func searchReplaceOptions(runtime flagView) map[string]interface{} { + opts := map[string]interface{}{} + if runtime.Bool("match-case") { + opts["match_case"] = true + } + if runtime.Bool("match-entire-cell") { + opts["match_entire_cell"] = true + } + if runtime.Bool("regex") { + opts["use_regex"] = true + } + if runtime.Bool("include-formulas") { + opts["match_formulas"] = true + } + return opts +} + +// CellsReplace wraps replace_data: find and replace text across a +// spreadsheet, with the same option controls as +cells-search. +var CellsReplace = common.Shortcut{ + Service: "sheets", + Command: "+cells-replace", + Description: "Find and replace text in a spreadsheet (case / regex / whole-cell / formula-text controls).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-replace"), + Validate: validateViaInput(replaceInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := replaceInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "replace_data", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := replaceInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "replace_data", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Always preview with --dry-run before running — replace can mutate every matching cell across the sheet.", + }, +} + +func replaceInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("find")) == "" { + return nil, common.FlagErrorf("--find is required") + } + if !runtime.Changed("replacement") { + return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)") + } + input := map[string]interface{}{ + "excel_id": token, + "search_term": runtime.Str("find"), + "replace_term": runtime.Str("replacement"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if r := strings.TrimSpace(runtime.Str("range")); r != "" { + input["range"] = r + } + if opts := searchReplaceOptions(runtime); len(opts) > 0 { + input["options"] = opts + } + return input, nil +} diff --git a/shortcuts/sheets/lark_sheet_search_replace_test.go b/shortcuts/sheets/lark_sheet_search_replace_test.go new file mode 100644 index 000000000..7e58abee3 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_search_replace_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestSearchReplaceShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + wantOptions map[string]interface{} + }{ + { + name: "+cells-search regex + match-case", + sc: CellsSearch, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--regex", "--match-case"}, + toolName: "search_data", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "search_term": "foo", + }, + wantOptions: map[string]interface{}{ + "match_case": true, + "use_regex": true, + }, + }, + { + name: "+cells-search all four options", + sc: CellsSearch, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "x", "--match-case", "--match-entire-cell", "--regex", "--include-formulas"}, + toolName: "search_data", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "search_term": "x", + }, + wantOptions: map[string]interface{}{ + "match_case": true, + "match_entire_cell": true, + "use_regex": true, + "match_formulas": true, + }, + }, + { + name: "+cells-replace empty replace deletes match", + sc: CellsReplace, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--replacement", ""}, + toolName: "replace_data", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "search_term": "foo", + "replace_term": "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + if tt.wantOptions != nil { + opts, _ := got["options"].(map[string]interface{}) + if opts == nil { + t.Fatalf("options missing: %#v", got) + } + for k, want := range tt.wantOptions { + if opts[k] != want { + t.Errorf("options[%q] = %v, want %v", k, opts[k], want) + } + } + } + }) + } +} + +func TestCellsReplace_RequireFlag(t *testing.T) { + t.Parallel() + // --replace not passed at all (vs empty string) should error. + stdout, stderr, err := runShortcutCapturingErr(t, CellsReplace, []string{ + "--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--dry-run", + }) + if err == nil { + t.Fatalf("expected error when --replace omitted; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "replace") { + t.Errorf("expected message about --replace; got=%s|%s|%v", stdout, stderr, err) + } +} diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go new file mode 100644 index 000000000..fcdd9667a --- /dev/null +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -0,0 +1,679 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_sheet_structure ─────────────────────────────────────── +// +// Wraps get_sheet_structure (read) and modify_sheet_structure (write, +// operation-enum dispatch). All region/position arguments use A1-style +// strings (1-based row numbers like "3:7" / "5", or column letters like +// "C:F" / "C"); dim-* / resize never expose 0-based int indices on the CLI +// surface, so there is no inclusive/exclusive ambiguity across commands. +// parseA1Range / parseA1Position handle parsing into the 0-based ints that +// dim-move's native v3 endpoint expects. +// +// +rows-resize / +cols-resize live in lark_sheet_range_operations (different +// tool); they are only grouped under "工作表" for discoverability. + +// SheetInfo wraps get_sheet_structure: row heights, column widths, hidden +// rows/cols, merged cells, row/column groups, and freeze counts for one +// sub-sheet (optionally limited to a range). +var SheetInfo = common.Shortcut{ + Service: "sheets", + Command: "+sheet-info", + Description: "Get a sub-sheet's layout metadata: row heights, column widths, hidden rows/cols, merges, groups, freeze.", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+sheet-info"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + _, _, err := resolveSheetSelector(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName)) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Frozen rows / columns are top-level fields and are returned regardless of --include.", + }, +} + +func sheetInfoInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token} + sheetSelectorForToolInput(input, sheetID, sheetName) + if r := strings.TrimSpace(runtime.Str("range")); r != "" { + input["range"] = r + } + if include := runtime.StrSlice("include"); len(include) > 0 { + if t := infoTypeFromInclude(include); t != "" { + input["info_type"] = t + } + } + return input +} + +// infoTypeFromInclude maps the fine-grained --include vocabulary to the +// tool's coarse info_type enum. When --include spans multiple categories +// (or asks for "frozen", which is always returned), we fall back to "all". +func infoTypeFromInclude(include []string) string { + groups := map[string]string{ + "row_heights": "row_heights_column_widths", + "col_widths": "row_heights_column_widths", + "hidden_rows": "hidden_infos", + "hidden_cols": "hidden_infos", + "groups": "group_infos", + "merges": "merged_cells_infos", + "frozen": "", // any info_type returns frozen; falling back to all is fine + } + seen := map[string]struct{}{} + for _, v := range include { + g, ok := groups[v] + if !ok || g == "" { + return "all" + } + seen[g] = struct{}{} + } + if len(seen) != 1 { + return "all" + } + for g := range seen { + return g + } + return "all" +} + +// ─── +dim-* (modify_sheet_structure) ────────────────────────────────── + +// DimInsert inserts blank rows / columns and optionally inherits style from +// the adjacent dimension. +var DimInsert = common.Shortcut{ + Service: "sheets", + Command: "+dim-insert", + Description: "Insert blank rows or columns at a given position.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+dim-insert"), + Validate: validateViaInput(dimInsertInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := dimInsertInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := dimInsertInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// dimInsertInput passes --position (1-based row number "3" or column letter +// "C") straight to the tool's `position` field; --count maps to `count`. +func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if !runtime.Changed("position") { + return nil, common.FlagErrorf("--position is required") + } + if !runtime.Changed("count") { + return nil, common.FlagErrorf("--count is required") + } + position := strings.TrimSpace(runtime.Str("position")) + if _, _, err := parseA1Position(position); err != nil { + return nil, common.FlagErrorf("invalid --position %q: %v", position, err) + } + count := runtime.Int("count") + if count <= 0 { + return nil, common.FlagErrorf("--count must be > 0 (got %d)", count) + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "insert", + "position": position, + "count": count, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + switch runtime.Str("inherit-style") { + case "before": + input["side"] = "before" + case "after": + input["side"] = "after" + } + return input, nil +} + +// DimDelete deletes rows / columns — irreversible, high-risk-write. +var DimDelete = common.Shortcut{ + Service: "sheets", + Command: "+dim-delete", + Description: "Delete rows or columns (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+dim-delete"), + Validate: validateDimRangeOp("delete"), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete") + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete") + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Row/column deletion is irreversible. Always preview with --dry-run first.", + }, +} + +// validateDimRangeOp returns a Validate closure that delegates to +// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an +// extra `op` argument. Token check happens here; the rest is the builder. +func validateDimRangeOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = dimRangeOpInput(runtime, token, sheetID, sheetName, op) + return err + } +} + +// validateDimGroupOp is the group/ungroup counterpart of validateDimRangeOp. +func validateDimGroupOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error { + return func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = dimGroupInput(runtime, token, sheetID, sheetName, op) + return err + } +} + +// DimHide / DimUnhide toggle visibility on a row/column range. +var DimHide = newDimRangeOpShortcut( + "+dim-hide", "Hide rows or columns within a range.", "hide", "write", +) +var DimUnhide = newDimRangeOpShortcut( + "+dim-unhide", "Unhide rows or columns within a range.", "unhide", "write", +) + +// DimGroup / DimUngroup manage row/column outline groups. +var DimGroup = newDimGroupShortcut( + "+dim-group", "Group rows or columns into an outline (collapsible).", "group", +) +var DimUngroup = newDimGroupShortcut( + "+dim-ungroup", "Remove a row/column outline group.", "ungroup", +) + +// DimFreeze freezes the first N rows or columns; --count 0 unfreezes that +// dimension. +var DimFreeze = common.Shortcut{ + Service: "sheets", + Command: "+dim-freeze", + Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+dim-freeze"), + Validate: validateViaInput(dimFreezeInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := dimFreezeInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := dimFreezeInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if !runtime.Changed("dimension") { + return nil, common.FlagErrorf("--dimension is required") + } + if !runtime.Changed("count") { + return nil, common.FlagErrorf("--count is required (0 unfreezes)") + } + if runtime.Int("count") < 0 { + return nil, common.FlagErrorf("--count must be >= 0") + } + dim := runtime.Str("dimension") + count := runtime.Int("count") + op := "freeze" + if count == 0 { + op = "unfreeze" + } + input := map[string]interface{}{"excel_id": token, "operation": op} + sheetSelectorForToolInput(input, sheetID, sheetName) + if op == "freeze" { + if dim == "row" { + input["freeze_rows"] = count + } else { + input["freeze_columns"] = count + } + } + return input, nil +} + +// dimRangeOpInput builds the tool input for delete/hide/unhide/group/ungroup +// which all take a `range` string field. --range is a 1-based A1 closed range +// ("3:7" / "5" for rows, "C:F" / "C" for columns) and passes straight through +// after format validation. +func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if !runtime.Changed("range") { + return nil, common.FlagErrorf("--range is required") + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + if _, _, _, err := parseA1Range(rangeStr); err != nil { + return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err) + } + input := map[string]interface{}{ + "excel_id": token, + "operation": op, + "range": rangeStr, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +// newDimRangeOpShortcut builds the shared shape for hide / unhide. +func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut { + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: desc, + Risk: risk, + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor(command), + Validate: validateDimRangeOp(op), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, op) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +// newDimGroupShortcut builds the shared shape for group / ungroup. It adds +// --depth (currently unused server-side — accepted for forward-compat per +// the canonical spec) and --group-state (group only, defaults to expand). +func newDimGroupShortcut(command, desc, op string) common.Shortcut { + flags := flagsFor(command) + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: desc, + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flags, + Validate: validateDimGroupOp(op), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := dimGroupInput(runtime, token, sheetID, sheetName, op) + return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := dimGroupInput(runtime, token, sheetID, sheetName, op) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) { + input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op) + if err != nil { + return nil, err + } + if op == "group" { + if gs := runtime.Str("group-state"); gs != "" { + input["group_state"] = gs + } + } + return input, nil +} + +// ─── A1 parsing helpers ─────────────────────────────────────────────── + +// parseA1Range parses an A1 closed range ("3:7" / "5" / "C:F" / "C") into +// the inferred dimension ("row" or "column") and 0-based inclusive indices. +// Single-element form yields startIdx == endIdx. Mixing digits and letters +// across the two sides ("3:C") is rejected. +func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) { + s = strings.TrimSpace(s) + if s == "" { + return "", 0, 0, fmt.Errorf("range is empty") + } + parts := strings.Split(s, ":") + if len(parts) > 2 { + return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") + } + dim1, idx1, err := parseA1Position(parts[0]) + if err != nil { + return "", 0, 0, err + } + if len(parts) == 1 { + return dim1, idx1, idx1, nil + } + dim2, idx2, err := parseA1Position(parts[1]) + if err != nil { + return "", 0, 0, err + } + if dim1 != dim2 { + return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") + } + if idx2 < idx1 { + return "", 0, 0, fmt.Errorf("end position is before start") + } + return dim1, idx1, idx2, nil +} + +// parseA1Position parses a single A1 position element: pure digits → row +// (1-based number, returned as 0-based idx); pure letters → column (letters +// case-insensitive, "A" → 0, "AA" → 26). +func parseA1Position(s string) (dimension string, idx int, err error) { + s = strings.TrimSpace(s) + if s == "" { + return "", 0, fmt.Errorf("position is empty") + } + isDigits := true + isLetters := true + for _, r := range s { + if r < '0' || r > '9' { + isDigits = false + } + if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) { + isLetters = false + } + } + if isDigits { + n, _ := strconv.Atoi(s) + if n <= 0 { + return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) + } + return "row", n - 1, nil + } + if isLetters { + return "column", letterToColumnIndex(s), nil + } + return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) +} + +// columnIndexToLetter converts a 0-based column index to the spreadsheet +// letter notation (0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA"). +// Used by +workbook helpers that need to format absolute column references. +func columnIndexToLetter(idx int) string { + if idx < 0 { + return "" + } + idx++ + var out []byte + for idx > 0 { + idx-- + out = append([]byte{byte('A' + idx%26)}, out...) + idx /= 26 + } + return string(out) +} + +// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ────── +// +// Moves a contiguous block of rows or columns to a new index in the same +// sheet via the native v3 move_dimension endpoint (not the One-OpenAPI +// dispatcher). CLI accepts --source-range (A1 closed range like "3:7" or +// "C:F") + --target (A1 single position like "12" or "H"); both are parsed +// into the 0-based int indices that v3 move_dimension expects. + +var DimMove = common.Shortcut{ + Service: "sheets", + Command: "+dim-move", + Description: "Move a contiguous block of rows or columns to a new position (re-numbers neighbors).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+dim-move"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + _, err := buildDimMovePlan(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + return common.NewDryRunAPI(). + POST(dimMovePath(token, sheetSelectorPlaceholder(sheetID, sheetName))). + Body(dimMoveBody(runtime)). + Set("spreadsheet_token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + // v3 move_dimension carries sheet_id in the path. Resolve + // sheet_name client-side when needed (reuses lookupSheetIndex + // which fetches workbook structure). + if sheetID == "" { + lookedID, _, err := lookupSheetIndex(ctx, runtime, token, "", sheetName) + if err != nil { + return err + } + sheetID = lookedID + } + data, err := runtime.CallAPI("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime)) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +// dimMovePlan is the parsed form of --source-range / --target. +type dimMovePlan struct { + dimension string // "row" / "column" + startIdx int // 0-based inclusive + endIdx int // 0-based inclusive + targetIdx int // 0-based; destination position (move inserts before this) +} + +// buildDimMovePlan parses --source-range + --target and enforces that the +// target dimension matches the source. Used by both Validate and Execute. +func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) { + if !runtime.Changed("source-range") || !runtime.Changed("target") { + return nil, common.FlagErrorf("--source-range and --target are required") + } + src := strings.TrimSpace(runtime.Str("source-range")) + dim, startIdx, endIdx, err := parseA1Range(src) + if err != nil { + return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err) + } + tgt := strings.TrimSpace(runtime.Str("target")) + tgtDim, tgtIdx, err := parseA1Position(tgt) + if err != nil { + return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err) + } + if tgtDim != dim { + return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim) + } + return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil +} + +// dimMovePath builds the native v3 move_dimension endpoint. sheet_id lives in +// the path (unlike the v2 dimension_range body that the earlier build used). +func dimMovePath(token, sheetID string) string { + return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension", + validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)) +} + +func dimMoveBody(runtime *common.RuntimeContext) map[string]interface{} { + plan, err := buildDimMovePlan(runtime) + if err != nil { + // Validate has already rejected this case; emit an empty body + // rather than panic on the dry-run path. + return map[string]interface{}{} + } + dim := "ROWS" + if plan.dimension == "column" { + dim = "COLUMNS" + } + return map[string]interface{}{ + "source": map[string]interface{}{ + "major_dimension": dim, + "start_index": plan.startIdx, + "end_index": plan.endIdx, + }, + "destination_index": plan.targetIdx, + } +} diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go new file mode 100644 index 000000000..827f33e75 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -0,0 +1,342 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestSheetStructureShortcuts_DryRun covers all 8 shortcuts in +// lark_sheet_sheet_structure (sheet-info + 7 dim-*) and verifies that the +// CLI's A1-style --range / --position / --count flags map straight through +// to the tool's `range` / `position` / `count` fields (or are normalised +// per shortcut's wire shape). +func TestSheetStructureShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+sheet-info with include single category → narrow info_type", + sc: SheetInfo, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--include", "row_heights,col_widths"}, + toolName: "get_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "info_type": "row_heights_column_widths", + }, + }, + { + name: "+sheet-info with mixed include → all", + sc: SheetInfo, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--include", "row_heights,merges"}, + toolName: "get_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "info_type": "all", + }, + }, + { + name: "+dim-insert row position=6 count=3 inherit-before", + sc: DimInsert, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "insert", + "sheet_id": testSheetID, + "position": "6", + "count": float64(3), + "side": "before", + }, + }, + { + name: "+dim-insert column position=C count=2", + sc: DimInsert, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "C", "--count", "2"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "insert", + "sheet_id": testSheetID, + "position": "C", + "count": float64(2), + }, + }, + { + name: "+dim-delete column B:D", + sc: DimDelete, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "delete", + "sheet_id": testSheetID, + "range": "B:D", + }, + }, + { + name: "+dim-hide row 3:5", + sc: DimHide, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:5"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "hide", + "sheet_id": testSheetID, + "range": "3:5", + }, + }, + { + name: "+dim-unhide column AA:AC", + sc: DimUnhide, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "AA:AC"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "unhide", + "sheet_id": testSheetID, + "range": "AA:AC", + }, + }, + { + name: "+dim-freeze row count=2 → freeze_rows", + sc: DimFreeze, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--count", "2"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "freeze", + "sheet_id": testSheetID, + "freeze_rows": float64(2), + }, + }, + { + name: "+dim-freeze count=0 → unfreeze", + sc: DimFreeze, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--count", "0"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "unfreeze", + "sheet_id": testSheetID, + }, + }, + { + name: "+dim-group row 1:5 fold", + sc: DimGroup, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--group-state", "fold"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "group", + "sheet_id": testSheetID, + "range": "1:5", + "group_state": "fold", + }, + }, + { + name: "+dim-ungroup row 1:5", + sc: DimUngroup, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "ungroup", + "sheet_id": testSheetID, + "range": "1:5", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestDimRange_Validation covers the A1 range parser's edge cases routed +// through +dim-hide (any --range shortcut works; we just need to exercise +// the validator). +func TestDimRange_Validation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + { + name: "end before start", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--dry-run"}, + want: "end position is before start", + }, + { + name: "mix row+column", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:C", "--dry-run"}, + want: "cannot mix row", + }, + { + name: "invalid characters", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--dry-run"}, + want: "expected pure digits", + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("expected %q substring; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) + } +} + +// TestDimMove_DryRun verifies the native v3 move_dimension payload shape. +// CLI's --source-range "1:3" (1-based inclusive) is parsed into +// source.{start_index=0, end_index=2} (0-based inclusive), and sheet_id is +// carried in the path, not the body. --target "11" → destination_index=10. +func TestDimMove_DryRun(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "1:3", "--target", "11", + }) + if len(calls) != 1 { + t.Fatalf("api calls = %d, want 1", len(calls)) + } + c := calls[0].(map[string]interface{}) + wantURL := "/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension" + if !strings.Contains(c["url"].(string), wantURL) { + t.Errorf("url = %v, want suffix %v", c["url"], wantURL) + } + body, _ := c["body"].(map[string]interface{}) + src, _ := body["source"].(map[string]interface{}) + if src["major_dimension"] != "ROWS" { + t.Errorf("source.major_dimension = %v, want ROWS", src["major_dimension"]) + } + if src["start_index"].(float64) != 0 { + t.Errorf("start_index = %v, want 0", src["start_index"]) + } + if src["end_index"].(float64) != 2 { + t.Errorf("end_index = %v, want 2 (0-based inclusive)", src["end_index"]) + } + if body["destination_index"].(float64) != 10 { + t.Errorf("destination_index = %v, want 10 (target \"11\" → 0-based 10)", body["destination_index"]) + } +} + +// TestDimMove_Column exercises the column path: --source-range "C:F" → +// COLUMNS / start=2 / end=5; --target "H" → destination_index=7. +func TestDimMove_Column(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "C:F", "--target", "H", + }) + c := calls[0].(map[string]interface{}) + body, _ := c["body"].(map[string]interface{}) + src, _ := body["source"].(map[string]interface{}) + if src["major_dimension"] != "COLUMNS" { + t.Errorf("major_dimension = %v, want COLUMNS", src["major_dimension"]) + } + if src["start_index"].(float64) != 2 || src["end_index"].(float64) != 5 { + t.Errorf("source = %v, want start=2 end=5", src) + } + if body["destination_index"].(float64) != 7 { + t.Errorf("destination_index = %v, want 7", body["destination_index"]) + } +} + +// TestDimMove_MismatchedDimension verifies that mixing source row + target +// column (or vice versa) is rejected at Validate. +func TestDimMove_MismatchedDimension(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "1:3", "--target", "H", "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "must match --source-range") { + t.Errorf("expected dimension-mismatch guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestParseA1Range covers parser edge cases directly. +func TestParseA1Range(t *testing.T) { + t.Parallel() + cases := []struct { + in string + dim string + start int + end int + wantErr bool + }{ + {"3:7", "row", 2, 6, false}, + {"5", "row", 4, 4, false}, + {"C:F", "column", 2, 5, false}, + {"C", "column", 2, 2, false}, + {"aa:ac", "column", 26, 28, false}, // lower-case letters accepted + {"", "", 0, 0, true}, + {"3:C", "", 0, 0, true}, + {"7:3", "", 0, 0, true}, + {"A1", "", 0, 0, true}, // cell ref, not a row/col range + {"3:5:7", "", 0, 0, true}, + {"0", "", 0, 0, true}, // rows are 1-based + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + t.Parallel() + dim, start, end, err := parseA1Range(c.in) + if c.wantErr { + if err == nil { + t.Errorf("parseA1Range(%q) = (%q, %d, %d, nil), want error", c.in, dim, start, end) + } + return + } + if err != nil { + t.Fatalf("parseA1Range(%q) unexpected error: %v", c.in, err) + } + if dim != c.dim || start != c.start || end != c.end { + t.Errorf("parseA1Range(%q) = (%q, %d, %d), want (%q, %d, %d)", c.in, dim, start, end, c.dim, c.start, c.end) + } + }) + } +} + +// TestColumnIndexToLetter exercises the corner cases of the letter helper +// (still in use by lark_sheet_workbook.go for absolute column refs). +func TestColumnIndexToLetter(t *testing.T) { + t.Parallel() + cases := []struct { + idx int + want string + }{ + {0, "A"}, {25, "Z"}, {26, "AA"}, {27, "AB"}, {51, "AZ"}, + {52, "BA"}, {701, "ZZ"}, {702, "AAA"}, + } + for _, c := range cases { + if got := columnIndexToLetter(c.idx); got != c.want { + t.Errorf("columnIndexToLetter(%d) = %q, want %q", c.idx, got, c.want) + } + } +} diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go new file mode 100644 index 000000000..6ea725b5a --- /dev/null +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -0,0 +1,1035 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_workbook ────────────────────────────────────────────── +// +// Wraps two tools behind the One-OpenAPI: get_workbook_structure (read) and +// modify_workbook_structure (write, dispatched by `operation` enum). +// +// CLI Risk tiers diverge intentionally from the tool's single endpoint: +// - +sheet-delete is high-risk-write (irreversible) +// - everything else is plain write +// +// +sheet-create only carries --url / --spreadsheet-token (no sheet selector): +// the create tool path needs no existing-sheet anchor, so the public sheet +// selector pair is dropped here to avoid a misleading XOR requirement. + +// WorkbookInfo wraps get_workbook_structure: list a workbook's sub-sheets +// with their metadata (sheet_id, title, dimensions, freeze rows and cols, +// index, hidden). First step for every sheets task — downstream sheet-level +// operations all depend on the sheet_id returned here. +var WorkbookInfo = common.Shortcut{ + Service: "sheets", + Command: "+workbook-info", + Description: "List sub-sheets of a spreadsheet with metadata (sheet_id, title, dimensions, freeze, hidden).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+workbook-info"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := resolveSpreadsheetToken(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "First step for every sheets task — capture sheet_id from the result before doing any sheet-level operation.", + }, +} + +// SheetCreate creates a new sub-sheet. --title is the new sheet's name; +// --index inserts at a specific position (omitted → appended). Default +// dimensions match the canonical schema (rows=100, cols=26 when omitted — +// tool's defaults differ but CLI surface stays predictable). +var SheetCreate = common.Shortcut{ + Service: "sheets", + Command: "+sheet-create", + Description: "Create a new sub-sheet with an optional position and initial dimensions.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+sheet-create"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + _, err = sheetCreateInput(runtime, token) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := sheetCreateInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := sheetCreateInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, error) { + if strings.TrimSpace(runtime.Str("title")) == "" { + return nil, common.FlagErrorf("--title is required") + } + if n := runtime.Int("row-count"); n < 0 || n > 50000 { + return nil, common.FlagErrorf("--row-count must be between 0 and 50000") + } + if n := runtime.Int("col-count"); n < 0 || n > 200 { + return nil, common.FlagErrorf("--col-count must be between 0 and 200") + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "create", + "sheet_name": strings.TrimSpace(runtime.Str("title")), + } + if runtime.Changed("index") { + input["target_index"] = runtime.Int("index") + } + if n := runtime.Int("row-count"); n > 0 { + input["rows"] = n + } + if n := runtime.Int("col-count"); n > 0 { + input["columns"] = n + } + return input, nil +} + +// sheetDeleteInput / sheetRenameInput / sheetVisibilityInput / +// sheetSetTabColorInput build the modify_workbook_structure body for the +// matching shortcut. Shared by standalone DryRun/Execute and by the +// +batch-update sub-op dispatch so both paths emit an identical body and the +// same friendly error when --sheet-id/--sheet-name (or the shortcut's own +// required flags) are missing. +func sheetDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("title")) == "" { + return nil, common.FlagErrorf("--title is required") + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "rename", + "new_name": strings.TrimSpace(runtime.Str("title")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +func sheetVisibilityInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + input := map[string]interface{}{"excel_id": token, "operation": op} + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if !runtime.Changed("color") { + return nil, common.FlagErrorf("--color is required (empty string clears)") + } + input := map[string]interface{}{ + "excel_id": token, + "operation": "set_tab_color", + "tab_color": runtime.Str("color"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input, nil +} + +// SheetDelete deletes a sub-sheet. high-risk-write — framework rejects +// without --yes. Always preview with --dry-run first to confirm the target. +var SheetDelete = common.Shortcut{ + Service: "sheets", + Command: "+sheet-delete", + Description: "Delete a sub-sheet (irreversible).", + Risk: "high-risk-write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+sheet-delete"), + Validate: validateViaInput(sheetDeleteInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := sheetDeleteInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := sheetDeleteInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Sheet deletion is irreversible. Always run with --dry-run first to verify the target sheet_id/sheet_name.", + }, +} + +// SheetRename renames a sub-sheet via --title (mapped to tool's new_name). +var SheetRename = common.Shortcut{ + Service: "sheets", + Command: "+sheet-rename", + Description: "Rename a sub-sheet.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+sheet-rename"), + Validate: validateViaInput(sheetRenameInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := sheetRenameInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := sheetRenameInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// SheetMove moves a sub-sheet to a new index. The tool requires sheet_id +// and source_index in addition to target_index. The CLI accepts: +// - --sheet-id / --sheet-name to identify the sheet +// - --source-index (optional) for explicit source position +// +// When --source-index is omitted, or when --sheet-name is used instead of +// --sheet-id, Execute issues a single get_workbook_structure read to derive +// the missing pieces. DryRun stays network-free: it uses placeholders +// for any field that would need that read. +var SheetMove = common.Shortcut{ + Service: "sheets", + Command: "+sheet-move", + Description: "Move a sub-sheet to a new position.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+sheet-move"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + if !runtime.Changed("index") { + return common.FlagErrorf("--index is required") + } + if runtime.Int("index") < 0 { + return common.FlagErrorf("--index must be >= 0") + } + if runtime.Changed("source-index") && runtime.Int("source-index") < 0 { + return common.FlagErrorf("--source-index must be >= 0") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input := map[string]interface{}{ + "excel_id": token, + "operation": "move", + "sheet_id": sheetSelectorPlaceholder(sheetID, sheetName), + "target_index": runtime.Int("index"), + "source_index": sourceIndexOrPlaceholder(runtime), + } + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + + resolvedID := sheetID + var sourceIndex int + needIDLookup := sheetID == "" + needIndexLookup := !runtime.Changed("source-index") + if needIDLookup || needIndexLookup { + lookedID, lookedIdx, err := lookupSheetIndex(ctx, runtime, token, sheetID, sheetName) + if err != nil { + return err + } + resolvedID = lookedID + sourceIndex = lookedIdx + } + if runtime.Changed("source-index") { + sourceIndex = runtime.Int("source-index") + } + + input := map[string]interface{}{ + "excel_id": token, + "operation": "move", + "sheet_id": resolvedID, + "source_index": sourceIndex, + "target_index": runtime.Int("index"), + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Pass --source-index when you already know it to avoid the extra read; otherwise CLI derives it from --sheet-id/--sheet-name.", + }, +} + +// sourceIndexOrPlaceholder returns the user-supplied source-index, or the +// string "" when DryRun should signal that Execute will derive it. +func sourceIndexOrPlaceholder(runtime *common.RuntimeContext) interface{} { + if runtime.Changed("source-index") { + return runtime.Int("source-index") + } + return "" +} + +// SheetCopy duplicates a sub-sheet. --title (optional) names the copy; +// --index (optional) places it. +var SheetCopy = common.Shortcut{ + Service: "sheets", + Command: "+sheet-copy", + Description: "Duplicate a sub-sheet, optionally renaming and repositioning the copy.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+sheet-copy"), + Validate: validateViaInput(sheetCopyInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := sheetCopyInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := sheetCopyInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func sheetCopyInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + input := map[string]interface{}{"excel_id": token, "operation": "duplicate"} + sheetSelectorForToolInput(input, sheetID, sheetName) + if t := strings.TrimSpace(runtime.Str("title")); t != "" { + input["new_name"] = t + } + if runtime.Changed("index") { + input["target_index"] = runtime.Int("index") + } + return input, nil +} + +// SheetHide / SheetUnhide toggle visibility. Visible bool semantics live in +// the operation enum so callers don't need a --visible flag. +var SheetHide = newSheetVisibilityShortcut( + "+sheet-hide", "Hide a sub-sheet from the tabs bar.", "hide", +) + +var SheetUnhide = newSheetVisibilityShortcut( + "+sheet-unhide", "Restore a hidden sub-sheet.", "unhide", +) + +func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { + return common.Shortcut{ + Service: "sheets", + Command: command, + Description: desc, + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor(command), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID := strings.TrimSpace(runtime.Str("sheet-id")) + sheetName := strings.TrimSpace(runtime.Str("sheet-name")) + _, err = sheetVisibilityInput(runtime, token, sheetID, sheetName, op) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := sheetVisibilityInput(runtime, token, sheetID, sheetName, op) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := sheetVisibilityInput(runtime, token, sheetID, sheetName, op) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + } +} + +// SheetSetTabColor sets the tab color of a sub-sheet. --color "" clears. +var SheetSetTabColor = common.Shortcut{ + Service: "sheets", + Command: "+sheet-set-tab-color", + Description: "Set or clear the tab color of a sub-sheet.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+sheet-set-tab-color"), + Validate: validateViaInput(sheetSetTabColorInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := sheetSetTabColorInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := sheetSetTabColorInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +// ─── +workbook-create (legacy OAPI, cli_status: cli-only) ──────────── +// +// Creates a brand-new spreadsheet via POST /sheets/v3/spreadsheets, then +// optionally fills the first sheet's header row and initial data block +// via a follow-up callTool(set_cell_range). Not exposed as an MCP tool — +// hence the direct legacy OAPI call instead of going through callTool. + +// WorkbookCreate creates a brand-new spreadsheet in the user's drive +// (optionally inside --folder-token) and can pre-fill the first row of +// headers and an initial data block. +var WorkbookCreate = common.Shortcut{ + Service: "sheets", + Command: "+workbook-create", + Description: "Create a new spreadsheet (optionally pre-filled with --headers and --values).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+workbook-create"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("title")) == "" { + return common.FlagErrorf("--title is required") + } + if runtime.Str("headers") != "" { + v, err := parseJSONFlag(runtime, "headers") + if err != nil { + return err + } + if _, ok := v.([]interface{}); !ok { + return common.FlagErrorf("--headers must be a JSON array") + } + } + if runtime.Str("values") != "" { + v, err := parseJSONFlag(runtime, "values") + if err != nil { + return err + } + rows, ok := v.([]interface{}) + if !ok { + return common.FlagErrorf("--values must be a JSON 2D array") + } + for i, r := range rows { + if _, ok := r.([]interface{}); !ok { + return common.FlagErrorf("--values[%d] must be an array", i) + } + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))} + if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { + body["folder_token"] = v + } + dry := common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets"). + Desc("create spreadsheet"). + Body(body) + if fill, _ := buildInitialFillInput(runtime); fill != nil { + fill["excel_id"] = "" + fill["sheet_id"] = "" // resolved from the workbook at execute time + wireBody, _ := buildToolBody("set_cell_range", fill) + dry.POST("/open-apis/sheet_ai/v2/spreadsheets//tools/invoke_write"). + Desc("fill headers + data via set_cell_range (sheet_id resolved after create)"). + Body(wireBody) + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := map[string]interface{}{"title": strings.TrimSpace(runtime.Str("title"))} + if v := strings.TrimSpace(runtime.Str("folder-token")); v != "" { + body["folder_token"] = v + } + data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, body) + if err != nil { + return err + } + ss := common.GetMap(data, "spreadsheet") + token := common.GetString(ss, "spreadsheet_token") + if token == "" { + token = common.GetString(ss, "token") + } + if token == "" { + return output.Errorf(output.ExitAPI, "api_error", "spreadsheet created but token missing in response") + } + + result := map[string]interface{}{"spreadsheet": ss} + + // --headers / --values are optional. buildInitialFillInput returns + // (nil, nil) when both are absent or empty, in which case we skip the + // fill entirely rather than dereferencing a nil map. + fill, err := buildInitialFillInput(runtime) + if err != nil { + return err + } + if fill != nil { + fill["excel_id"] = token + // set_cell_range needs a concrete sheet selector; the create + // response doesn't echo the default sheet's id, so read it back. + firstSheetID, err := lookupFirstSheetID(ctx, runtime, token) + if err != nil { + return workbookCreatedButFillFailed(token, ss, + fmt.Sprintf("resolving its first sheet for initial fill failed: %v", err)) + } + fill["sheet_id"] = firstSheetID + fillOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", fill) + if err != nil { + return workbookCreatedButFillFailed(token, ss, + fmt.Sprintf("initial fill failed: %v", err)) + } + result["initial_fill"] = fillOut + } + runtime.Out(result, nil) + return nil + }, + Tips: []string{ + "--headers and --values are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.", + }, +} + +// workbookCreatedButFillFailed builds a structured partial-success error for the +// window where the spreadsheet POST succeeded but the follow-up initial fill did +// not. The new spreadsheet_token is surfaced in the error detail so callers can +// retry the fill (+cells-set / +csv-put) or delete the orphan, instead of only +// finding the token interpolated into a bare error string. +func workbookCreatedButFillFailed(token string, spreadsheet interface{}, reason string) error { + return &output.ExitError{ + Code: output.ExitAPI, + Detail: &output.ErrDetail{ + Type: "partial_success", + Message: fmt.Sprintf("spreadsheet %s created but %s", token, reason), + Hint: "the spreadsheet exists; retry the fill with the returned spreadsheet_token, or delete it", + Detail: map[string]interface{}{ + "spreadsheet_token": token, + "spreadsheet": spreadsheet, + }, + }, + } +} + +// buildInitialFillInput zips --headers + --values into a single set_cell_range +// payload writing to the first sheet starting at A1. +func buildInitialFillInput(runtime *common.RuntimeContext) (map[string]interface{}, error) { + var rows [][]interface{} + if runtime.Str("headers") != "" { + v, _ := parseJSONFlag(runtime, "headers") + headerArr, _ := v.([]interface{}) + row := make([]interface{}, 0, len(headerArr)) + for _, h := range headerArr { + row = append(row, map[string]interface{}{"value": h}) + } + rows = append(rows, row) + } + if runtime.Str("values") != "" { + v, _ := parseJSONFlag(runtime, "values") + dataArr, _ := v.([]interface{}) + for _, r := range dataArr { + cells, _ := r.([]interface{}) + row := make([]interface{}, 0, len(cells)) + for _, c := range cells { + row = append(row, map[string]interface{}{"value": c}) + } + rows = append(rows, row) + } + } + if len(rows) == 0 { + return nil, nil + } + maxCols := 0 + for _, r := range rows { + if len(r) > maxCols { + maxCols = len(r) + } + } + if maxCols == 0 { + // --headers '[]' / --values '[]' parse to rows that carry no cells. + // There is nothing to write and a 0-width range ("A1:1") would be + // illegal, so treat it as "no initial fill" — same contract as the + // len(rows)==0 case above — and let the caller skip the write. + return nil, nil + } + // Normalize rows to the same length so cells matrix is rectangular. + for i := range rows { + for len(rows[i]) < maxCols { + rows[i] = append(rows[i], map[string]interface{}{}) + } + } + endCol := columnIndexToLetter(maxCols - 1) + rangeStr := fmt.Sprintf("A1:%s%d", endCol, len(rows)) + return map[string]interface{}{ + "range": rangeStr, + "cells": rows, + // sheet_id is left for the caller to fill: Execute resolves the new + // workbook's first sheet via lookupFirstSheetID. The DryRun preview + // can't know it yet (the workbook doesn't exist), so it stays absent. + }, nil +} + +// ─── +workbook-export (legacy OAPI, cli_status: cli-only) ──────────── +// +// Drives the three-step export flow against the classic drive endpoints: +// create export task → poll task status → optional binary download. +// Not exposed as an MCP tool. + +// WorkbookExport drives the three-step export flow: create task → poll → +// optionally download. CSV mode requires --sheet-id (the API exports one +// sheet at a time as csv). +var WorkbookExport = common.Shortcut{ + Service: "sheets", + Command: "+workbook-export", + Description: "Export a spreadsheet to xlsx or a single sheet to csv (async + poll + optional download).", + Risk: "read", + Scopes: []string{"sheets:spreadsheet:read", "docs:document:export", "drive:drive.metadata:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+workbook-export"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + if ext == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" { + return common.FlagErrorf("--sheet-id is required when --file-extension=csv") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + body := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": ext, + } + if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { + body["sub_id"] = sid + } + dry := common.NewDryRunAPI(). + POST("/open-apis/drive/v1/export_tasks"). + Desc("create export task"). + Body(body). + GET("/open-apis/drive/v1/export_tasks/"). + Desc("poll task status"). + Params(map[string]interface{}{"token": token}) + if strings.TrimSpace(runtime.Str("output-path")) != "" { + dry.GET("/open-apis/drive/v1/export_tasks/file//download"). + Desc("download exported file") + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + ext := runtime.Str("file-extension") + if ext == "" { + ext = "xlsx" + } + body := map[string]interface{}{ + "token": token, + "type": "sheet", + "file_extension": ext, + } + if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" { + body["sub_id"] = sid + } + taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body) + if err != nil { + return err + } + ticket := common.GetString(taskData, "ticket") + if ticket == "" { + return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing") + } + + result := map[string]interface{}{ + "ticket": ticket, + "file_extension": ext, + } + + // Poll up to ~30s for completion. + var fileToken, fileName string + for attempt := 0; attempt < 15; attempt++ { + status, err := pollExportTask(runtime, token, ticket) + if err != nil { + return err + } + switch status.JobStatus { + case 0: // success + fileToken = status.FileToken + fileName = status.FileName + result["file_token"] = fileToken + result["file_name"] = fileName + result["file_size"] = status.FileSize + attempt = 999 // break outer loop + case 1, 2: // pending / in progress + time.Sleep(2 * time.Second) + continue + default: // any non-zero status outside the in-progress window is a failure + if status.JobErrorMsg != "" { + return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg) + } + return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus) + } + } + if fileToken == "" { + result["status"] = "polling_timeout" + runtime.Out(result, nil) + return nil + } + + outPath := strings.TrimSpace(runtime.Str("output-path")) + if outPath == "" { + runtime.Out(result, nil) + return nil + } + + saved, err := downloadExportFile(ctx, runtime, fileToken, outPath, fileName) + if err != nil { + return err + } + result["saved_path"] = saved + runtime.Out(result, nil) + return nil + }, + Tips: []string{ + "Polls up to ~30s (15 × 2s). For very large workbooks rerun and pass --output-path to capture the file once status flips to success.", + }, +} + +type exportTaskStatus struct { + JobStatus int + JobErrorMsg string + FileToken string + FileName string + FileSize int64 + FileExtension string +} + +func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) { + data, err := runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)), + map[string]interface{}{"token": token}, + nil, + ) + if err != nil { + return exportTaskStatus{}, err + } + result := common.GetMap(data, "result") + if result == nil { + return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket) + } + js, _ := util.ToFloat64(result["job_status"]) + fs, _ := util.ToFloat64(result["file_size"]) + return exportTaskStatus{ + JobStatus: int(js), + JobErrorMsg: common.GetString(result, "job_error_msg"), + FileToken: common.GetString(result, "file_token"), + FileName: common.GetString(result, "file_name"), + FileSize: int64(fs), + FileExtension: common.GetString(result, "file_extension"), + }, nil +} + +func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outPath, preferredName string) (string, error) { + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)), + }, larkcore.WithFileDownload()) + if err != nil { + return "", output.ErrNetwork("download failed: %s", err) + } + if apiResp.StatusCode >= 400 { + return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + } + target := outPath + if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() { + name := strings.TrimSpace(preferredName) + if name == "" { + name = client.ResolveFilename(apiResp) + } + target = filepath.Join(outPath, name) + } + if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{ + ContentType: apiResp.Header.Get("Content-Type"), + ContentLength: int64(len(apiResp.RawBody)), + }, strings.NewReader(string(apiResp.RawBody))); err != nil { + return "", common.WrapSaveErrorByCategory(err, "io") + } + resolved, _ := runtime.FileIO().ResolvePath(target) + if resolved == "" { + resolved = target + } + return resolved, nil +} + +// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical +// id + current 0-based index. Caller is responsible for ensuring at least one +// of sheetID/sheetName is non-empty. +func lookupSheetIndex(ctx context.Context, runtime *common.RuntimeContext, token, sheetID, sheetName string) (resolvedID string, index int, err error) { + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + if err != nil { + return "", 0, err + } + m, ok := out.(map[string]interface{}) + if !ok { + return "", 0, output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output") + } + sheets, _ := m["sheets"].([]interface{}) + for _, raw := range sheets { + sm, ok := raw.(map[string]interface{}) + if !ok { + continue + } + id, _ := sm["sheet_id"].(string) + // get_workbook_structure surfaces the sub-sheet's display name as + // "title"; older/alt payloads use "sheet_name". Match either so a + // --sheet-name lookup resolves regardless of the field name. + name, _ := sm["sheet_name"].(string) + if name == "" { + name, _ = sm["title"].(string) + } + if (sheetID != "" && id == sheetID) || (sheetName != "" && name == sheetName) { + idx, ok := util.ToFloat64(sm["index"]) + if !ok { + return "", 0, output.Errorf(output.ExitAPI, "tool_output", "sheet entry missing index field") + } + return id, int(idx), nil + } + } + target := sheetID + if target == "" { + target = sheetName + } + return "", 0, output.Errorf(output.ExitAPI, "not_found", fmt.Sprintf("sheet %q not found in workbook", target)) +} + +// lookupFirstSheetID returns the sheet_id of the sub-sheet at index 0 (the +// default sheet of a freshly created workbook). Used by +workbook-create to +// target the initial-fill set_cell_range write — set_cell_range rejects an +// empty sheet selector ("sheet_id or sheet_name is required"), and the v3 +// create-spreadsheet response does not echo the default sheet's id. +func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, token string) (string, error) { + out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{ + "excel_id": token, + }) + if err != nil { + return "", err + } + m, ok := out.(map[string]interface{}) + if !ok { + return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned non-object output") + } + sheets, _ := m["sheets"].([]interface{}) + bestID := "" + bestIdx := -1 + for _, raw := range sheets { + sm, ok := raw.(map[string]interface{}) + if !ok { + continue + } + id, _ := sm["sheet_id"].(string) + if id == "" { + continue + } + idx, ok := util.ToFloat64(sm["index"]) + if !ok { + // No index field — fall back to first encountered sheet. + if bestID == "" { + bestID = id + } + continue + } + if bestIdx < 0 || int(idx) < bestIdx { + bestIdx = int(idx) + bestID = id + } + } + if bestID == "" { + return "", output.Errorf(output.ExitAPI, "tool_output", "get_workbook_structure returned no sheets") + } + return bestID, nil +} diff --git a/shortcuts/sheets/lark_sheet_workbook_test.go b/shortcuts/sheets/lark_sheet_workbook_test.go new file mode 100644 index 000000000..c7cad75f9 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_workbook_test.go @@ -0,0 +1,439 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestWorkbookShortcuts_DryRun covers all 9 lark_sheet_workbook shortcuts +// (WorkbookInfo + 8 sheet-* variants) by asserting the One-OpenAPI body +// the dry-run renders. Together they exercise every dispatch arm of +// modify_workbook_structure plus the read tool. +func TestWorkbookShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+workbook-info read", + sc: WorkbookInfo, + args: []string{"--url", testURL}, + toolName: "get_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + }, + }, + { + name: "+sheet-create with all options", + sc: SheetCreate, + args: []string{"--url", testURL, "--title", "Q1", "--index", "1", "--row-count", "300", "--col-count", "10"}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "create", + "sheet_name": "Q1", + "target_index": float64(1), + "rows": float64(300), + "columns": float64(10), + }, + }, + { + name: "+sheet-delete by id", + sc: SheetDelete, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "delete", + "sheet_id": testSheetID, + }, + }, + { + name: "+sheet-rename by name", + sc: SheetRename, + args: []string{"--url", testURL, "--sheet-name", "汇总", "--title", "Q1 汇总"}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "rename", + "sheet_name": "汇总", + "new_name": "Q1 汇总", + }, + }, + { + name: "+sheet-copy without explicit title", + sc: SheetCopy, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "duplicate", + "sheet_id": testSheetID, + }, + }, + { + name: "+sheet-copy with new title and index", + sc: SheetCopy, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--title", "副本", "--index", "0"}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "duplicate", + "sheet_id": testSheetID, + "new_name": "副本", + "target_index": float64(0), + }, + }, + { + name: "+sheet-hide", + sc: SheetHide, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "hide", + "sheet_id": testSheetID, + }, + }, + { + name: "+sheet-unhide", + sc: SheetUnhide, + args: []string{"--url", testURL, "--sheet-id", testSheetID}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "unhide", + "sheet_id": testSheetID, + }, + }, + { + name: "+sheet-set-tab-color hex", + sc: SheetSetTabColor, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--color", "#FF0000"}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "set_tab_color", + "sheet_id": testSheetID, + "tab_color": "#FF0000", + }, + }, + { + name: "+sheet-set-tab-color empty clears", + sc: SheetSetTabColor, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--color", ""}, + toolName: "modify_workbook_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "set_tab_color", + "sheet_id": testSheetID, + "tab_color": "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestSheetMove_DryRunResolvePlaceholders verifies the move shortcut emits +// placeholders for fields it would otherwise have to look up +// at execute time. DryRun must stay network-free. +func TestSheetMove_DryRunResolvePlaceholders(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + wantSheetID string + wantSourceIdx interface{} + }{ + { + name: "id only, no source-index → both literal + placeholder", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--index", "0"}, + wantSheetID: testSheetID, + wantSourceIdx: "", + }, + { + name: "name only → sheet_id placeholder + source_index placeholder", + args: []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"}, + wantSheetID: "", + wantSourceIdx: "", + }, + { + name: "id + source-index → both literal", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--index", "0", "--source-index", "5"}, + wantSheetID: testSheetID, + wantSourceIdx: float64(5), + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, SheetMove, tt.args) + input := decodeToolInput(t, body, "modify_workbook_structure") + if got := input["sheet_id"]; got != tt.wantSheetID { + t.Errorf("sheet_id = %#v, want %#v", got, tt.wantSheetID) + } + if got := input["source_index"]; got != tt.wantSourceIdx { + t.Errorf("source_index = %#v, want %#v", got, tt.wantSourceIdx) + } + if got := input["target_index"]; got != float64(0) { + t.Errorf("target_index = %#v, want 0", got) + } + }) + } +} + +// TestSheetDelete_HighRiskWriteRequiresYes verifies the framework gate on +// high-risk-write — exit code 10 (confirmation_required) without --yes. +func TestSheetDelete_HighRiskWriteRequiresYes(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, SheetDelete, []string{"--url", testURL, "--sheet-id", testSheetID}) + if err == nil { + t.Fatalf("expected confirmation_required error; got nil. stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") { + t.Errorf("expected confirmation envelope; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestWorkbook_Validation covers a few critical validation paths shared +// across the package's helpers (XOR token, XOR sheet selector, required +// flags). +func TestWorkbook_Validation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + sc common.Shortcut + args []string + wantMsg string + }{ + { + name: "+workbook-info needs --url or --spreadsheet-token", + sc: WorkbookInfo, + args: []string{}, + wantMsg: "at least one of --url or --spreadsheet-token", + }, + { + name: "+workbook-info rejects both url and token", + sc: WorkbookInfo, + args: []string{"--url", testURL, "--spreadsheet-token", testToken}, + wantMsg: "mutually exclusive", + }, + { + name: "+sheet-delete needs sheet selector", + sc: SheetDelete, + args: []string{"--url", testURL}, + wantMsg: "at least one of --sheet-id or --sheet-name", + }, + { + name: "+sheet-create requires --title", + sc: SheetCreate, + args: []string{"--url", testURL}, + wantMsg: "required flag(s) \"title\" not set", + }, + { + name: "+sheet-create row-count over cap", + sc: SheetCreate, + args: []string{"--url", testURL, "--title", "X", "--row-count", "999999"}, + wantMsg: "--row-count must be between", + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run")) + if err == nil { + t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, tt.wantMsg) { + t.Errorf("error message missing %q; got=%s", tt.wantMsg, combined) + } + }) + } +} + +// ─── +workbook-create / +workbook-export (legacy OAPI) ─────────────── + +// TestWorkbookCreate_DryRun verifies the two-step plan (create +// spreadsheet + optional set_cell_range follow-up) is rendered. +func TestWorkbookCreate_DryRun(t *testing.T) { + t.Parallel() + + t.Run("minimal title only", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"}) + if len(calls) != 1 { + t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls)) + } + c := calls[0].(map[string]interface{}) + if c["url"] != "/open-apis/sheets/v3/spreadsheets" { + t.Errorf("url = %v, want /open-apis/sheets/v3/spreadsheets", c["url"]) + } + body, _ := c["body"].(map[string]interface{}) + if body["title"] != "MySheet" { + t.Errorf("body.title = %v, want MySheet", body["title"]) + } + }) + + t.Run("with headers and data → 2-step plan", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookCreate, []string{ + "--title", "Sales", + "--headers", `["Name","Score"]`, + "--values", `[["alice",95],["bob",88]]`, + }) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls)) + } + fill := calls[1].(map[string]interface{}) + if !strings.Contains(fill["url"].(string), "/sheet_ai/v2/spreadsheets/") { + t.Errorf("fill url = %v, want sheet_ai/v2 path", fill["url"]) + } + body, _ := fill["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + if input["range"] != "A1:B3" { + t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"]) + } + }) +} + +// TestWorkbookCreate_DataValidation rejects bad JSON shape. +func TestWorkbookCreate_DataValidation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + {"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"}, + {"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run")) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) + } +} + +// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on +// --output-path. The order should be: POST → GET (poll) → optional GET +// (download). +func TestWorkbookExport_DryRun(t *testing.T) { + t.Parallel() + + t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"}) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls)) + } + create := calls[0].(map[string]interface{}) + if create["url"] != "/open-apis/drive/v1/export_tasks" { + t.Errorf("first url = %v", create["url"]) + } + body, _ := create["body"].(map[string]interface{}) + if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken { + t.Errorf("create body = %#v", body) + } + }) + + t.Run("csv → 3 steps, with sub_id", func(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, WorkbookExport, []string{ + "--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1", + "--output-path", "/tmp/out.csv", + }) + if len(calls) != 3 { + t.Fatalf("api calls = %d, want 3", len(calls)) + } + body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{}) + if body["sub_id"] != "sh1" { + t.Errorf("csv export missing sub_id: %#v", body) + } + dl := calls[2].(map[string]interface{}) + if !strings.Contains(dl["url"].(string), "/export_tasks/file/") { + t.Errorf("download url = %v", dl["url"]) + } + }) + + t.Run("csv requires --sheet-id", func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, WorkbookExport, []string{ + "--url", testURL, "--file-extension", "csv", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "--sheet-id is required") { + t.Errorf("expected sheet-id guard; got=%s|%s|%v", stdout, stderr, err) + } + }) +} + +// assertInputEquals compares the decoded tool input map against the wanted +// fields. Extra fields in `got` are allowed (defaults, optional fields); +// every key in `want` must match exactly. +func assertInputEquals(t *testing.T, got, want map[string]interface{}) { + t.Helper() + for k, wv := range want { + gv, ok := got[k] + if !ok { + t.Errorf("missing input key %q (got=%#v)", k, got) + continue + } + if !deepEqualJSON(gv, wv) { + t.Errorf("input[%q] = %#v, want %#v", k, gv, wv) + } + } +} + +// deepEqualJSON compares JSON-shaped values (post-Unmarshal) — handles +// the fact that numbers come back as float64 and maps as map[string]interface{}. +func deepEqualJSON(a, b interface{}) bool { + switch av := a.(type) { + case map[string]interface{}: + bv, ok := b.(map[string]interface{}) + if !ok || len(av) != len(bv) { + return false + } + for k, v := range av { + if !deepEqualJSON(v, bv[k]) { + return false + } + } + return true + case []interface{}: + bv, ok := b.([]interface{}) + if !ok || len(av) != len(bv) { + return false + } + for i := range av { + if !deepEqualJSON(av[i], bv[i]) { + return false + } + } + return true + } + return a == b +} diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go new file mode 100644 index 000000000..07768614f --- /dev/null +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -0,0 +1,824 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/csv" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "path/filepath" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +// ─── lark_sheet_write_cells ─────────────────────────────────────────── +// +// Wraps: +// - set_cell_range (powers +cells-set / +cells-set-style / +// +dropdown-set / +dropdown-update / +dropdown-delete) +// - set_range_from_csv (powers +csv-put) +// +// +cells-set-image is a `cli_only_derivative` shortcut (needs a local file +// upload before calling set_cell_range); it lives in the cli-only batch +// where the upload helper is shared with +workbook-create / +dim-move / +// +workbook-export. +// +// All set_cell_range-backed shortcuts construct a cells matrix whose +// dimensions exactly match the target range — the tool errors on mismatch. + +// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells +// (JSON), with an optional --copy-to-range to replicate the written block +// across a larger area (formula refs auto-shift). +var CellsSet = common.Shortcut{ + Service: "sheets", + Command: "+cells-set", + Description: "Write values / formulas / styles / comments / data validation / embed-image to a cell range.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-set"), + Validate: validateViaInput(cellsSetInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := cellsSetInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := cellsSetInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("range")) == "" { + return nil, common.FlagErrorf("--range is required") + } + cells, err := requireJSONArray(runtime, "cells") + if err != nil { + return nil, err + } + input := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "cells": cells, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if !runtime.Bool("allow-overwrite") { + input["allow_overwrite"] = false + } + if copyTo := strings.TrimSpace(runtime.Str("copy-to-range")); copyTo != "" { + input["copy_to_range"] = copyTo + } + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } + return input, nil +} + +// CellsSetStyle stamps a single style block across every cell in --range. +// Style is composed from a dozen flat flags (background-color, font-color, +// font-size, font-style, font-weight, font-line, horizontal-alignment, +// vertical-alignment, word-wrap, number-format) plus --border-styles for +// the only field that still needs a nested object. At least one flag must +// be set. +var CellsSetStyle = common.Shortcut{ + Service: "sheets", + Command: "+cells-set-style", + Description: "Apply style flags to every cell in a range (values / formulas untouched).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-set-style"), + Validate: validateViaInput(cellsSetStyleInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := cellsSetStyleInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := cellsSetStyleInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + if rangeStr == "" { + return nil, common.FlagErrorf("--range is required") + } + rows, cols, err := rangeDimensions(rangeStr) + if err != nil { + return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) + } + if err := requireAnyStyleFlag(runtime); err != nil { + return nil, err + } + cellStyle := buildCellStyleFromFlags(runtime) + borderStyles, err := borderStylesFromFlag(runtime) + if err != nil { + return nil, err + } + cells := make([][]interface{}, rows) + for r := range cells { + row := make([]interface{}, cols) + for c := range row { + cell := map[string]interface{}{} + if len(cellStyle) > 0 { + cell["cell_styles"] = cellStyle + } + if borderStyles != nil { + cell["border_styles"] = borderStyles + } + row[c] = cell + } + cells[r] = row + } + input := map[string]interface{}{ + "excel_id": token, + "range": rangeStr, + "cells": cells, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } + return input, nil +} + +// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing +// plain values. Use +cells-set for anything richer (formula / style / note). +var CsvPut = common.Shortcut{ + Service: "sheets", + Command: "+csv-put", + Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+csv-put"), // includes the hidden --range alias (defined in the base flags table) + PostMount: func(cmd *cobra.Command) { + // --range is an accepted alias for --start-cell (see csvPutInput). + // Neither is individually required; exactly one must be set. flag-defs + // marks --start-cell required, so clear that annotation and switch to a + // one-required group — otherwise cobra rejects `--range A1` for a + // missing --start-cell before the handler ever runs. + if fl := cmd.Flags().Lookup("start-cell"); fl != nil { + delete(fl.Annotations, cobra.BashCompOneRequiredFlag) + } + cmd.MarkFlagsOneRequired("start-cell", "range") + }, + Validate: validateViaInput(csvPutInput), + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := csvPutInput(runtime, token, sheetID, sheetName) + dr := invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", input) + if rng, ok := csvPutWriteRangeFromInput(input); ok { + dr = dr.Set("writes_range", rng) + } + return dr + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := csvPutInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_range_from_csv", input) + if err != nil { + return err + } + if rng, ok := csvPutWriteRangeFromInput(input); ok { + if m, isMap := out.(map[string]interface{}); isMap { + m["writes_range"] = rng + } + } + runtime.Out(out, nil) + return nil + }, +} + +// csvPutWriteRangeFromInput computes the rectangle +csv-put will actually write, +// from the built tool input (start_cell + csv). +csv-put pastes from the anchor +// and auto-expands to the CSV's own row/column count — the footprint is the +// result, not a user-set boundary. Surfacing it (e.g. "B2:D4") in dry-run and in +// the success envelope lets agents see how far a paste reaches before it +// silently overwrites neighbouring cells (use --allow-overwrite=false to block +// that). Returns ok=false when the anchor is not a single cell or the CSV has no +// parseable fields. +func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) { + anchor, _ := input["start_cell"].(string) + csvText, _ := input["csv"].(string) + if anchor == "" || csvText == "" { + return "", false + } + col0, row0, ok := splitCellRef(anchor) + if !ok { + return "", false + } + r := csv.NewReader(strings.NewReader(csvText)) + r.FieldsPerRecord = -1 // tolerate ragged rows; we only need the max width + records, err := r.ReadAll() + if err != nil || len(records) == 0 { + return "", false + } + cols := 0 + for _, rec := range records { + if len(rec) > cols { + cols = len(rec) + } + } + if cols == 0 { + return "", false + } + endCol := columnIndexToLetter(col0 + cols - 1) + endRow := row0 + len(records) // row0 is 0-based; +len(records) is the 1-based bottom row + return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true +} + +func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + if strings.TrimSpace(runtime.Str("csv")) == "" { + return nil, common.FlagErrorf("--csv is required") + } + anchor := strings.TrimSpace(runtime.Str("start-cell")) + // --range is accepted as an alias for --start-cell. +csv-get and +cells-set + // locate with --range, so agents routinely carry --range over to +csv-put and + // hit a guaranteed first-try failure. Honor it when --start-cell was not + // explicitly set — guard on Changed, not emptiness, because --start-cell + // defaults to "A1" and is therefore never empty. A range like "A1:H17" + // collapses to its top-left cell; +csv-put pastes from the anchor and + // auto-expands, so the range's lower-right bound is irrelevant. + // + // Standalone enforces "one of --start-cell / --range" via cobra's + // MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs + // cobra, so without an explicit check the default "A1" silently wins and the + // paste lands at A1 instead of failing like the standalone command. Mirror + // the standalone contract: when --start-cell is absent, --range is mandatory. + if !runtime.Changed("start-cell") { + rng := strings.TrimSpace(runtime.Str("range")) + if rng == "" { + return nil, common.FlagErrorf("--start-cell or --range is required") + } + anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0]) + } + if anchor == "" { + return nil, common.FlagErrorf("--start-cell is required") + } + if _, _, ok := splitCellRef(anchor); !ok { + return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor) + } + input := map[string]interface{}{ + "excel_id": token, + "csv": runtime.Str("csv"), + "start_cell": anchor, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if !runtime.Bool("allow-overwrite") { + input["allow_overwrite"] = false + } + return input, nil +} + +// ─── +dropdown-* (set_cell_range via data_validation) ───────────────── +// +// All three dropdown shortcuts stamp a `data_validation` block on every cell +// of the target range(s). set / update / delete differ in (a) how many +// ranges they accept and (b) whether the block is populated or null. + +// DropdownSet places a single dropdown on one range. +var DropdownSet = common.Shortcut{ + Service: "sheets", + Command: "+dropdown-set", + Description: "Attach a dropdown / data-validation list to every cell in --range.", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+dropdown-set"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateViaInput(dropdownSetInput)(ctx, runtime); err != nil { + return err + } + warnDropdownSourceRangeHighlight(runtime) + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + input, _ := dropdownSetInput(runtime, token, sheetID, sheetName) + return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + input, err := dropdownSetInput(runtime, token, sheetID, sheetName) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, +} + +func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if err := requireSheetSelector(sheetID, sheetName); err != nil { + return nil, err + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + if rangeStr == "" { + return nil, common.FlagErrorf("--range is required") + } + rows, cols, err := rangeDimensions(rangeStr) + if err != nil { + return nil, common.FlagErrorf("--range %q: %v", rangeStr, err) + } + validation, err := buildDropdownValidation(runtime) + if err != nil { + return nil, err + } + cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation}) + input := map[string]interface{}{ + "excel_id": token, + "range": rangeStr, + "cells": cells, + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if err := validateInputAgainstSchema(runtime, input); err != nil { + return nil, err + } + return input, nil +} + +// NOTE: +dropdown-update and +dropdown-delete were originally drafted here +// but moved to lark_sheet_batch_update (B7) per the spec: multi-range +// dropdown CRUD now goes through batch_update for atomicity. They'll land in +// the batch_update file alongside +cells-batch-set-style. + +// ─── shared dropdown helpers ────────────────────────────────────────── + +// buildDropdownValidation packs --options or --source-range plus --colors / +// --multiple / --highlight into the data_validation block expected by +// set_cell_range. Field names follow the canonical +// set_cell_range.data_validation schema: +// +// --options -> {type: "list", items: } +// --source-range -> {type: "listFromRange", range: } +// --multiple -> support_multiple_values (bool) +// --colors -> highlight_colors (string array, hex) +// --highlight -> enable_highlight (bool, tri-state via Changed) +// +// --options and --source-range are XOR (caller must pass exactly one). +// --colors length may be shorter than the source size (options length or +// source-range cell count) — server cycles remaining slots through a +// built-in 10-color palette — but must not exceed it. +// +// --highlight is tri-state: omitted leaves enable_highlight off the body so the +// server's new default (true) applies; --highlight=true stamps an explicit true; +// --highlight=false stamps false to turn the highlight off. Using Changed() lets +// us distinguish "not passed" from "explicit false" — required because the +// server-side default flipped from false to true and a plain cobra Bool can no +// longer carry the opt-out signal. +func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { + sourceSize, dv, err := dropdownTypeAndItems(runtime) + if err != nil { + return nil, err + } + if runtime.Str("colors") != "" { + colors, err := requireJSONArray(runtime, "colors") + if err != nil { + return nil, err + } + if len(colors) > sourceSize { + return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) + } + dv["highlight_colors"] = colors + } + if runtime.Bool("multiple") { + dv["support_multiple_values"] = true + } + if runtime.Changed("highlight") { + dv["enable_highlight"] = runtime.Bool("highlight") + } + return dv, nil +} + +// dropdownTypeAndItems resolves the XOR between --options and --source-range +// and returns (sourceSize, partial dv with type+items|range set). sourceSize +// is the option count for `list` mode or the source-range cell count for +// `listFromRange` mode — used to validate --colors length. +func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error) { + optsRaw := runtime.Str("options") + sourceRange := strings.TrimSpace(runtime.Str("source-range")) + switch { + case optsRaw != "" && sourceRange != "": + return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one") + case optsRaw == "" && sourceRange == "": + return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required") + case optsRaw != "": + options, err := requireJSONArray(runtime, "options") + if err != nil { + return 0, nil, err + } + return len(options), map[string]interface{}{ + "type": "list", + "items": options, + }, nil + default: // sourceRange != "" + rows, cols, err := rangeDimensions(sourceRange) + if err != nil { + return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err) + } + return rows * cols, map[string]interface{}{ + "type": "listFromRange", + "range": sourceRange, + }, nil + } +} + +// validateDropdownSourceOrOptions runs the XOR + --colors length check at +// Validate time so +dropdown-update / +dropdown-delete can fail fast without +// reaching the body-build step. Returns the dropdown source size (options +// length for list mode, source-range cell count for listFromRange) so +// callers can size their cells matrix. +func validateDropdownSourceOrOptions(runtime flagView) (int, error) { + sourceSize, _, err := dropdownTypeAndItems(runtime) + if err != nil { + return 0, err + } + if runtime.Str("colors") != "" { + colors, err := requireJSONArray(runtime, "colors") + if err != nil { + return 0, err + } + if len(colors) > sourceSize { + return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize) + } + } + return sourceSize, nil +} + +// dropdownSourceRangeHighlightLimit is the cell-count cap above which the +// server marks the dropdown's options as invalid when highlight is on. +// Source: byted-sheet core LIST_WITH_COLOR_MAX_COUNT +// (sheet-packages/.../dataValidation/list/ListFromRangeValidation.ts:49). +// Beyond this, ListFromRangeValidation.checkOptionsValid() sets +// isOptionError=true (highlight + range > 2000 is an unsupported combo). +const dropdownSourceRangeHighlightLimit = 2000 + +// warnDropdownSourceRangeHighlight emits a soft stderr warning when the user +// targets a --source-range larger than dropdownSourceRangeHighlightLimit while +// highlight is on (the server-side default and the most common path). +// Inline --options is not subject to this limit (server has no inline count +// or per-item length cap; only the listFromRange + highlight combo is). +// Validate phase only — never blocks the request. Caller must already have +// confirmed the source-or-options validation passed. +func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) { + sourceRange := strings.TrimSpace(runtime.Str("source-range")) + if sourceRange == "" { + return // inline --options mode — no server-side size cap applies + } + // highlight is tri-state: omitted = ON (server default), --highlight=true + // = ON, --highlight=false = OFF. Only the OFF case avoids the warning. + if runtime.Changed("highlight") && !runtime.Bool("highlight") { + return + } + rows, cols, err := rangeDimensions(sourceRange) + if err != nil { + return // already errored upstream; don't double-report + } + cellCount := rows * cols + if cellCount <= dropdownSourceRangeHighlightLimit { + return + } + fmt.Fprintf(runtime.IO().ErrOut, + "warning: --source-range covers %d cells; server marks the dropdown as option-error when highlight is on and the source exceeds %d cells. Pass --highlight=false to suppress this.\n", + cellCount, dropdownSourceRangeHighlightLimit) +} + +// ─── range parsing helpers ──────────────────────────────────────────── + +// rangeDimensions parses an A1 range like "A1:C5" / "A1" / "sheet1!B2:D10" +// and returns its row / column counts. Errors on non-rectangular forms like +// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total +// from get_sheet_structure, outside the scope of pure local parsing. +func rangeDimensions(rangeStr string) (rows, cols int, err error) { + if idx := strings.Index(rangeStr, "!"); idx >= 0 { + rangeStr = rangeStr[idx+1:] + } + rangeStr = strings.TrimSpace(rangeStr) + if rangeStr == "" { + return 0, 0, fmt.Errorf("empty range") + } + parts := strings.SplitN(rangeStr, ":", 2) + if len(parts) == 1 { + // single cell, e.g. "A1" + if _, _, ok := splitCellRef(parts[0]); !ok { + return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) + } + return 1, 1, nil + } + startCol, startRow, ok1 := splitCellRef(parts[0]) + endCol, endRow, ok2 := splitCellRef(parts[1]) + if !ok1 || !ok2 { + return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) + } + if endRow < startRow || endCol < startCol { + return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) + } + return endRow - startRow + 1, endCol - startCol + 1, nil +} + +// splitCellRef parses "A1" → (col=0, row=0, true). Returns false for any +// non-rectangular form (pure column "A", pure row "1", invalid chars). +func splitCellRef(s string) (col, row int, ok bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, 0, false + } + var colEnd int + for i, r := range s { + if r >= '0' && r <= '9' { + colEnd = i + break + } + colEnd = i + 1 + } + if colEnd == 0 || colEnd == len(s) { + return 0, 0, false + } + col = letterToColumnIndex(s[:colEnd]) + if col < 0 { + return 0, 0, false + } + n, err := strconv.Atoi(s[colEnd:]) + if err != nil || n < 1 { + return 0, 0, false + } + return col, n - 1, true +} + +// letterToColumnIndex converts spreadsheet letter notation to a 0-based +// column index ("A" → 0, "Z" → 25, "AA" → 26). Returns -1 on bad input. +func letterToColumnIndex(letters string) int { + letters = strings.ToUpper(strings.TrimSpace(letters)) + if letters == "" { + return -1 + } + n := 0 + for _, c := range letters { + if c < 'A' || c > 'Z' { + return -1 + } + n = n*26 + int(c-'A'+1) + } + return n - 1 +} + +// fillCellsMatrix returns a rows×cols matrix where every cell is the same +// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a +// single attribute (style / data_validation) across an entire range. +func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} { + cells := make([][]interface{}, rows) + for r := range cells { + row := make([]interface{}, cols) + for c := range row { + cell := make(map[string]interface{}, len(prototype)) + for k, v := range prototype { + cell[k] = v + } + row[c] = cell + } + cells[r] = row + } + return cells +} + +// ─── +cells-set-image (cli_only_derivative) ────────────────────────── +// +// The backing tool (set_cell_range) is in mcp-tools.json, but the CLI +// shortcut also needs a local-file upload before it can call the tool. +// That extra step doesn't fit the One-OpenAPI dispatcher, so the spec +// marks this shortcut cli_only_derivative — the CLI uploads the image +// to drive (parent_type=sheet_image) and then writes the returned +// file_token into the target cell via callTool(set_cell_range) with a +// rich_text embed-image entry. + +// CellsSetImage uploads a local image to drive (parent_type=sheet_image, +// parent_node=spreadsheet token) and then writes a rich_text embed-image +// into the target single-cell range via the set_cell_range tool. +var CellsSetImage = common.Shortcut{ + Service: "sheets", + Command: "+cells-set-image", + Description: "Embed a local image into a single cell (uploads via drive, then set_cell_range with rich_text embed-image).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+cells-set-image"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := resolveSpreadsheetToken(runtime); err != nil { + return err + } + if _, _, err := resolveSheetSelector(runtime); err != nil { + return err + } + r := strings.TrimSpace(runtime.Str("range")) + if r == "" { + return common.FlagErrorf("--range is required") + } + rows, cols, err := rangeDimensions(r) + if err != nil { + return common.FlagErrorf("--range %q: %v", r, err) + } + if rows != 1 || cols != 1 { + return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols) + } + imgPath := strings.TrimSpace(runtime.Str("image")) + if imgPath == "" { + return common.FlagErrorf("--image is required") + } + // Validate path safety here (not just at Execute) so --dry-run also + // rejects unsafe paths instead of giving a false-positive preview. + // SafeLocalFlagPath checks path safety only (abs/traversal/outside-cwd), + // not existence, so legitimate relative paths still dry-run cleanly; + // the Execute-time Stat below still reports a missing/unreadable file. + if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil { + return output.ErrValidation("%s", err) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + sheetID, sheetName, _ := resolveSheetSelector(runtime) + imgPath := strings.TrimSpace(runtime.Str("image")) + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(imgPath) + } + setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "sheet_id": sheetSelectorPlaceholder(sheetID, sheetName), + "cells": [][]interface{}{{map[string]interface{}{ + "rich_text": []map[string]interface{}{{ + "type": "embed-image", + "text": "", + "image_token": "", + "image_width": "", + "image_height": "", + }}, + }}}, + }) + return common.NewDryRunAPI(). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc("upload local image to drive (parent_type=sheet_image)"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": "sheet_image", + "parent_node": token, + "size": "", + "file": "@" + imgPath, + }). + POST(toolInvokePath(token, ToolKindWrite)). + Desc("embed file_token into the cell via set_cell_range"). + Body(setCellBody) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + sheetID, sheetName, err := resolveSheetSelector(runtime) + if err != nil { + return err + } + imgPath := strings.TrimSpace(runtime.Str("image")) + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(imgPath) + } + info, err := runtime.FileIO().Stat(imgPath) + if err != nil { + return common.WrapInputStatError(err) + } + imgFile, err := runtime.FileIO().Open(imgPath) + if err != nil { + return common.WrapInputStatError(err) + } + imgCfg, _, err := image.DecodeConfig(imgFile) + imgFile.Close() + if err != nil { + return fmt.Errorf("decode image dimensions: %w", err) + } + fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ + FilePath: imgPath, + FileName: fileName, + FileSize: info.Size(), + ParentType: "sheet_image", + ParentNode: &token, + }) + if err != nil { + return err + } + + setCellInput := map[string]interface{}{ + "excel_id": token, + "range": strings.TrimSpace(runtime.Str("range")), + "cells": [][]interface{}{{map[string]interface{}{ + "rich_text": []map[string]interface{}{{ + "type": "embed-image", + "text": "", + "image_token": fileToken, + "image_width": imgCfg.Width, + "image_height": imgCfg.Height, + }}, + }}}, + } + sheetSelectorForToolInput(setCellInput, sheetID, sheetName) + setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput) + if err != nil { + return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err) + } + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "set_cell_range": setCellOut, + }, nil) + return nil + }, + Tips: []string{ + "--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.", + }, +} diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go new file mode 100644 index 000000000..00ba2c810 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -0,0 +1,542 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestWriteCellsShortcuts_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sc common.Shortcut + args []string + toolName string + wantInput map[string]interface{} + }{ + { + name: "+cells-set with --cells bare 2D array", + sc: CellsSet, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B2", + "--cells", `[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]`, + }, + toolName: "set_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1:B2", + "cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}, map[string]interface{}{"value": float64(2)}}, []interface{}{map[string]interface{}{"value": float64(3)}, map[string]interface{}{"value": float64(4)}}}, + }, + }, + { + name: "+cells-set --allow-overwrite=false sends false explicitly", + sc: CellsSet, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", + "--cells", `[[{"value":1}]]`, + "--allow-overwrite=false", + }, + toolName: "set_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A1", + "cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}}}, + "allow_overwrite": false, + }, + }, + { + name: "+cells-set --copy-to-range passes copy_to_range", + sc: CellsSet, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "H2", + "--cells", `[[{"formula":"=A2*B2"}]]`, + "--copy-to-range", "H2:H100", + }, + toolName: "set_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "H2", + "cells": []interface{}{[]interface{}{map[string]interface{}{"formula": "=A2*B2"}}}, + "copy_to_range": "H2:H100", + }, + }, + { + name: "+csv-put inline csv", + sc: CsvPut, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--csv", "a,b,c\n1,2,3", + "--start-cell", "B3", + }, + toolName: "set_range_from_csv", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "csv": "a,b,c\n1,2,3", + "start_cell": "B3", + }, + }, + { + name: "+dropdown-set fans out cells matrix", + sc: DropdownSet, + args: []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", + "--options", `["a","b"]`, + "--multiple", + }, + toolName: "set_cell_range", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "sheet_id": testSheetID, + "range": "A2:A4", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, tt.sc, tt.args) + got := decodeToolInput(t, body, tt.toolName) + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestDropdownSet_CellsShape inspects the 3×1 matrix produced from +// --range A2:A4 to confirm the data_validation prototype is replicated. +// Also covers --colors / --highlight emitting the canonical +// `highlight_colors` / `enable_highlight` field names (not the legacy +// `colors` / `highlight_options`). +func TestDropdownSet_CellsShape(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", "--options", `["a","b"]`, "--multiple", + "--colors", `["#FFE699","#bff7d9"]`, "--highlight", + }) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + if len(cells) != 3 { + t.Fatalf("cells rows = %d, want 3 (A2:A4)", len(cells)) + } + for i, row := range cells { + r, _ := row.([]interface{}) + if len(r) != 1 { + t.Errorf("row %d cols = %d, want 1", i, len(r)) + } + cell, _ := r[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + if dv == nil { + t.Errorf("row %d cell missing data_validation: %#v", i, cell) + continue + } + if dv["type"] != "list" { + t.Errorf("row %d data_validation.type = %v, want list", i, dv["type"]) + } + items, _ := dv["items"].([]interface{}) + if len(items) != 2 || items[0] != "a" || items[1] != "b" { + t.Errorf("row %d data_validation.items = %#v, want [\"a\",\"b\"]", i, dv["items"]) + } + if dv["support_multiple_values"] != true { + t.Errorf("row %d data_validation.support_multiple_values = %v, want true", i, dv["support_multiple_values"]) + } + if _, hasLegacy := dv["multiple_values"]; hasLegacy { + t.Errorf("row %d data_validation should not emit legacy `multiple_values`", i) + } + colors, _ := dv["highlight_colors"].([]interface{}) + if len(colors) != 2 || colors[0] != "#FFE699" || colors[1] != "#bff7d9" { + t.Errorf("row %d data_validation.highlight_colors = %#v, want [\"#FFE699\",\"#bff7d9\"]", i, dv["highlight_colors"]) + } + if dv["enable_highlight"] != true { + t.Errorf("row %d data_validation.enable_highlight = %v, want true", i, dv["enable_highlight"]) + } + if _, hasLegacy := dv["colors"]; hasLegacy { + t.Errorf("row %d data_validation should not emit legacy `colors`", i) + } + if _, hasLegacy := dv["highlight_options"]; hasLegacy { + t.Errorf("row %d data_validation should not emit legacy `highlight_options`", i) + } + } +} + +// TestDropdownSet_HighlightTriState pins down the tri-state semantics of +// --highlight after the server flipped enable_highlight's default from false +// to true. The translator uses runtime.Changed() to tell "user did not pass +// the flag" apart from "user passed --highlight=false": +// +// - omitted → no enable_highlight key in body (server applies its +// new default = true) +// - --highlight → enable_highlight=true (presence-only cobra form) +// - --highlight=true → enable_highlight=true (explicit form) +// - --highlight=false → enable_highlight=false (the only way to opt out; +// the documented "plain dropdown" path) +func TestDropdownSet_HighlightTriState(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + wantKey bool + wantValue bool + }{ + { + name: "omitted leaves enable_highlight off the body", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`}, + wantKey: false, + }, + { + name: "presence form (--highlight) stamps true", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight"}, + wantKey: true, + wantValue: true, + }, + { + name: "explicit --highlight=true stamps true", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=true"}, + wantKey: true, + wantValue: true, + }, + { + name: "explicit --highlight=false stamps false (the opt-out path)", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=false"}, + wantKey: true, + wantValue: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownSet, tc.args) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row0, _ := cells[0].([]interface{}) + cell, _ := row0[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + got, has := dv["enable_highlight"] + if has != tc.wantKey { + t.Fatalf("enable_highlight key present = %v, want %v (dv = %#v)", has, tc.wantKey, dv) + } + if tc.wantKey && got != tc.wantValue { + t.Errorf("enable_highlight = %v (%T), want %v", got, got, tc.wantValue) + } + }) + } +} + +// TestDropdownSet_ColorsLongerThanOptions checks the early Validate-time +// error when --colors length exceeds the dropdown source size (options +// length in list mode). Equal-or-shorter lengths are accepted (server +// cycles the rest through a built-in palette). +func TestDropdownSet_ColorsLongerThanOptions(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", + "--options", `["a","b"]`, + "--colors", `["#FFE699","#bff7d9","#ffb3b3"]`, + "--dry-run", + }) + if err == nil { + t.Fatal("expected --colors length error, got nil") + } + if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") { + t.Errorf("error message missing length-overflow hint:\nerr=%v\nstderr=%s", err, stderr) + } +} + +// TestDropdownSet_ColorsShorterAccepted verifies the partial-colors case: +// fewer colors than options is legal — array is forwarded as-is and the +// server fills remaining slots from its default palette. +func TestDropdownSet_ColorsShorterAccepted(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A2:A4", + "--options", `["a","b","c","d"]`, + "--colors", `["#FFE699","#bff7d9"]`, + }) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row0, _ := cells[0].([]interface{}) + cell, _ := row0[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + colors, _ := dv["highlight_colors"].([]interface{}) + if len(colors) != 2 { + t.Errorf("highlight_colors length = %d, want 2 (forwarded as-is)", len(colors)) + } +} + +// TestDropdownSet_ListFromRange verifies --source-range emits +// data_validation.type=listFromRange + data_validation.range, paired with +// --colors / --highlight propagating to highlight_colors / enable_highlight. +func TestDropdownSet_ListFromRange(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "B2:B21", + "--source-range", "Sheet1!T1:T3", + "--colors", `["#cce8ff","#ffd6e7","#e6e6e6"]`, + "--highlight", + }) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row0, _ := cells[0].([]interface{}) + cell, _ := row0[0].(map[string]interface{}) + dv, _ := cell["data_validation"].(map[string]interface{}) + if dv["type"] != "listFromRange" { + t.Errorf("data_validation.type = %v, want listFromRange", dv["type"]) + } + if dv["range"] != "Sheet1!T1:T3" { + t.Errorf("data_validation.range = %v, want Sheet1!T1:T3 (verbatim, server normalizes)", dv["range"]) + } + if _, hasItems := dv["items"]; hasItems { + t.Errorf("listFromRange mode should not emit `items`: %#v", dv) + } + if dv["enable_highlight"] != true { + t.Errorf("data_validation.enable_highlight = %v, want true", dv["enable_highlight"]) + } + colors, _ := dv["highlight_colors"].([]interface{}) + if len(colors) != 3 { + t.Errorf("highlight_colors length = %d, want 3", len(colors)) + } +} + +// TestDropdownSet_ListFromRange_ColorsLongerThanCells rejects --colors +// longer than the source range cell count (T1:T3 has 3 cells, 4 colors +// must be refused). +func TestDropdownSet_ListFromRange_ColorsLongerThanCells(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "B2:B21", + "--source-range", "Sheet1!T1:T3", + "--colors", `["#a","#b","#c","#d"]`, + "--highlight", + "--dry-run", + }) + if err == nil { + t.Fatal("expected --colors length error, got nil") + } + if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") { + t.Errorf("error message missing source-size hint:\nerr=%v\nstderr=%s", err, stderr) + } +} + +// TestDropdownSet_XorBothSet rejects passing both --options and +// --source-range. +func TestDropdownSet_XorBothSet(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "B2:B21", + "--options", `["a","b"]`, + "--source-range", "Sheet1!T1:T3", + "--dry-run", + }) + if err == nil { + t.Fatal("expected XOR error, got nil") + } + if !strings.Contains(stderr, "mutually exclusive") && !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("error message missing XOR hint:\nerr=%v\nstderr=%s", err, stderr) + } +} + +// TestDropdownSet_XorNeitherSet rejects passing neither --options nor +// --source-range. +func TestDropdownSet_XorNeitherSet(t *testing.T) { + t.Parallel() + _, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "B2:B21", + "--dry-run", + }) + if err == nil { + t.Fatal("expected required-one error, got nil") + } + if !strings.Contains(stderr, "one of --options") && !strings.Contains(err.Error(), "one of --options") { + t.Errorf("error message missing required-one hint:\nerr=%v\nstderr=%s", err, stderr) + } +} + +// TestCellsSetStyle_FlatFlags verifies that the 11 flat style flags + +// --border-styles compose into cell_styles + border_styles per cell. +func TestCellsSetStyle_FlatFlags(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, CellsSetStyle, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B1", + "--font-weight", "bold", + "--background-color", "#ffff00", + "--horizontal-alignment", "center", + "--border-styles", `{"top":{"style":"solid"}}`, + }) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row, _ := cells[0].([]interface{}) + cell, _ := row[0].(map[string]interface{}) + style, _ := cell["cell_styles"].(map[string]interface{}) + if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" || style["horizontal_alignment"] != "center" { + t.Errorf("cell_styles wrong: %#v", style) + } + if cell["border_styles"] == nil { + t.Fatalf("border_styles missing on cell: %#v", cell) + } + if _, leaked := style["border_styles"]; leaked { + t.Errorf("border_styles leaked into cell_styles: %#v", style) + } +} + +func TestCellsSetStyle_RequiresAtLeastOneFlag(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSetStyle, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B2", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "at least one style flag") { + t.Errorf("expected style-flag guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +func TestCellsSet_RequiresJSONArray(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", "--cells", `{"foo":"bar"}`, "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + // Schema validator fires first now: "--cells: expected type \"array\", got \"object\"". + // Either the schema phrasing or the legacy requireJSONArray phrasing is + // acceptable — both pin the same contract. + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, `expected type "array"`) && !strings.Contains(combined, "must be a JSON array") { + t.Errorf("expected array-type guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is +// rendered, including the parent_type=sheet_image upload metadata. +func TestCellsSetImage_DryRun(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, CellsSetImage, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", + "--image", "./README.md", // any existing-shaped path; dry-run skips stat + }) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls)) + } + upload := calls[0].(map[string]interface{}) + if upload["url"] != "/open-apis/drive/v1/medias/upload_all" { + t.Errorf("upload url = %v", upload["url"]) + } + ubody, _ := upload["body"].(map[string]interface{}) + if ubody["parent_type"] != "sheet_image" { + t.Errorf("parent_type = %v, want sheet_image", ubody["parent_type"]) + } + if ubody["parent_node"] != testToken { + t.Errorf("parent_node = %v, want token", ubody["parent_node"]) + } + + embed := calls[1].(map[string]interface{}) + body, _ := embed["body"].(map[string]interface{}) + input := decodeToolInput(t, body, "set_cell_range") + cells, _ := input["cells"].([]interface{}) + row, _ := cells[0].([]interface{}) + cell, _ := row[0].(map[string]interface{}) + rt, _ := cell["rich_text"].([]interface{}) + if len(rt) != 1 { + t.Fatalf("rich_text len = %d, want 1", len(rt)) + } + item, _ := rt[0].(map[string]interface{}) + if item["type"] != "embed-image" { + t.Errorf("rich_text.type = %v, want embed-image", item["type"]) + } + if item["image_token"] != "" { + t.Errorf("image_token = %v, want ", item["image_token"]) + } + if item["text"] != "" { + t.Errorf("text = %v, want empty string", item["text"]) + } + if item["image_width"] != "" { + t.Errorf("image_width = %v, want ", item["image_width"]) + } + if item["image_height"] != "" { + t.Errorf("image_height = %v, want ", item["image_height"]) + } +} + +func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1:B2", "--image", "./foo.png", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be exactly one cell") { + t.Errorf("expected single-cell guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestCellsSetImage_DryRunRejectsUnsafePath guards that an unsafe --image path +// (e.g. an absolute path) is rejected during Validate, so --dry-run fails the +// same way as a real run instead of printing a misleading success preview. +func TestCellsSetImage_DryRunRejectsUnsafePath(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--range", "A1", "--image", "/etc/hosts", "--dry-run", + }) + if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be a relative path") { + t.Errorf("expected unsafe-path guard during dry-run; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestRangeDimensions exercises the A1 parser's corner cases used by +// cells-set-style / dropdown-set / rows-resize / cols-resize. +func TestRangeDimensions(t *testing.T) { + t.Parallel() + cases := []struct { + in string + wantRows int + wantCols int + wantErr bool + }{ + {"A1", 1, 1, false}, + {"A1:B2", 2, 2, false}, + {"sheet1!C3:E10", 8, 3, false}, + {"A:C", 0, 0, true}, // whole column not supported + {"3:6", 0, 0, true}, // whole row not supported + {"B2:A1", 0, 0, true}, // end before start + {"", 0, 0, true}, + } + var unusedSheet common.Shortcut = CellsSet // touch the common import + _ = unusedSheet + for _, c := range cases { + rows, cols, err := rangeDimensions(c.in) + if c.wantErr { + if err == nil { + t.Errorf("rangeDimensions(%q): want error, got rows=%d cols=%d", c.in, rows, cols) + } + continue + } + if err != nil { + t.Errorf("rangeDimensions(%q) unexpected error: %v", c.in, err) + } + if rows != c.wantRows || cols != c.wantCols { + t.Errorf("rangeDimensions(%q) = (%d,%d), want (%d,%d)", c.in, rows, cols, c.wantRows, c.wantCols) + } + } +} diff --git a/shortcuts/sheets/sheet_ai_api.go b/shortcuts/sheets/sheet_ai_api.go new file mode 100644 index 000000000..eb4368476 --- /dev/null +++ b/shortcuts/sheets/sheet_ai_api.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ToolKind selects the One-OpenAPI endpoint and its rate-limit bucket. +// +// - ToolKindRead → POST .../tools/invoke_read (scope sheets:spreadsheet:read, 10 qps) +// - ToolKindWrite → POST .../tools/invoke_write (scope sheets:spreadsheet:write_only, 5 qps) +type ToolKind string + +const ( + ToolKindRead ToolKind = "read" + ToolKindWrite ToolKind = "write" +) + +// toolInvokePath returns the full One-OpenAPI invoke path for the given +// spreadsheet token + tool kind. Network-free, safe in DryRun. +func toolInvokePath(token string, kind ToolKind) string { + suffix := "invoke_read" + if kind == ToolKindWrite { + suffix = "invoke_write" + } + return fmt.Sprintf("/open-apis/sheet_ai/v2/spreadsheets/%s/tools/%s", + validate.EncodePathSegment(token), suffix) +} + +// buildToolBody constructs the One-OpenAPI request body for a tool invocation. +// `input` is serialized to a JSON string per the API contract; callers pass +// a typed Go map and never need to handle JSON encoding themselves. +func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) { + inputJSON, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("encode tool input: %w", err) + } + return map[string]interface{}{ + "tool_name": toolName, + "input": string(inputJSON), + }, nil +} + +// callTool invokes a sheet-ai tool via the One-OpenAPI endpoint and decodes +// the JSON-string `output` field into a generic Go value (typically +// map[string]interface{}). When the tool returns an empty `output`, callTool +// returns nil with no error. +// +// kind must match the tool's read/write classification — passing a read tool +// to invoke_write (or vice versa) results in a 403 from the gateway. +func callTool( + ctx context.Context, + runtime *common.RuntimeContext, + token string, + kind ToolKind, + toolName string, + input map[string]interface{}, +) (interface{}, error) { + body, err := buildToolBody(toolName, input) + if err != nil { + return nil, err + } + + raw, err := runtime.RawAPI("POST", toolInvokePath(token, kind), nil, body) + if err != nil { + return nil, err + } + + envelope, ok := raw.(map[string]interface{}) + if !ok { + return nil, output.Errorf(output.ExitAPI, "tool_response", + "tool %q: unexpected non-JSON-object response: %v", toolName, raw) + } + code, _ := util.ToFloat64(envelope["code"]) + if code != 0 { + msg, _ := envelope["msg"].(string) + return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"]) + } + data, _ := envelope["data"].(map[string]interface{}) + rawOutput, _ := data["output"].(string) + if rawOutput == "" { + return nil, nil + } + + var out interface{} + if err := json.Unmarshal([]byte(rawOutput), &out); err != nil { + return nil, output.Errorf(output.ExitAPI, "tool_output", + "tool %q returned invalid JSON output: %v", toolName, err) + } + return out, nil +} + +// invokeToolDryRun renders the One-OpenAPI request the shortcut would send. +// The wire-format body (with input serialized to a JSON string) is preserved +// for fidelity, and a decoded tool_input map is surfaced alongside so humans +// don't have to mentally unmarshal the string field. +func invokeToolDryRun( + token string, + kind ToolKind, + toolName string, + input map[string]interface{}, +) *common.DryRunAPI { + wireBody, _ := buildToolBody(toolName, input) + return common.NewDryRunAPI(). + POST(toolInvokePath(token, kind)). + Body(wireBody). + Set("spreadsheet_token", token). + Set("tool_name", toolName). + Set("tool_input", input) +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index ac5e94487..2f1e76ea7 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -5,67 +5,103 @@ package sheets import "github.com/larksuite/cli/shortcuts/common" -// Shortcuts returns all sheets shortcuts. +// Shortcuts returns all lark-sheets shortcuts. The list is grouped by +// canonical skill to mirror the sheet-skill-spec layout +// (lark_sheet_workbook → lark_sheet_float_image). +// +// Any shortcut whose command is registered in data/flag-schemas.json gets a +// PrintFlagSchema closure attached, so the framework can serve +// `--print-schema --flag-name ` locally. func Shortcuts() []common.Shortcut { + all := shortcutList() + // Gate on the codegen'd command set (flag_schemas_gen.go) so registration + // — which runs on every CLI invocation — does not parse the 256KB + // flag-schemas.json. The blob is unmarshaled lazily (printFlagSchemaFor / + // the validate fast-path) only when actually needed. + for i := range all { + if _, ok := commandsWithSchema[all[i].Command]; ok { + all[i].PrintFlagSchema = printFlagSchemaFor(all[i].Command) + } + } + return all +} + +func shortcutList() []common.Shortcut { return []common.Shortcut{ - // Spreadsheet management + // lark_sheet_workbook + WorkbookInfo, SheetCreate, - SheetInfo, - SheetExport, + SheetDelete, + SheetRename, + SheetMove, + SheetCopy, + SheetHide, + SheetUnhide, + SheetSetTabColor, + WorkbookCreate, + WorkbookExport, - // Sheet management - SheetCreateSheet, - SheetCopySheet, - SheetDeleteSheet, - SheetUpdateSheet, + // lark_sheet_sheet_structure + SheetInfo, + DimInsert, + DimDelete, + DimHide, + DimUnhide, + DimFreeze, + DimGroup, + DimUngroup, + DimMove, - // Cell data - SheetRead, - SheetWrite, - SheetAppend, - SheetFind, - SheetReplace, + // lark_sheet_read_data + CellsGet, + CsvGet, + DropdownGet, - // Cell style and merge - SheetSetStyle, - SheetBatchSetStyle, - SheetMergeCells, - SheetUnmergeCells, + // lark_sheet_search_replace + CellsSearch, + CellsReplace, - // Cell images - SheetWriteImage, + // lark_sheet_write_cells + CellsSet, + CellsSetStyle, + CellsSetImage, + CsvPut, + DropdownSet, - // Row/column management - SheetAddDimension, - SheetInsertDimension, - SheetUpdateDimension, - SheetMoveDimension, - SheetDeleteDimension, + // lark_sheet_range_operations + CellsClear, + CellsMerge, + CellsUnmerge, + RowsResize, + ColsResize, + RangeMove, + RangeCopy, + RangeFill, + RangeSort, - // Filter views - SheetCreateFilterView, - SheetUpdateFilterView, - SheetListFilterViews, - SheetGetFilterView, - SheetDeleteFilterView, - SheetCreateFilterViewCondition, - SheetUpdateFilterViewCondition, - SheetListFilterViewConditions, - SheetGetFilterViewCondition, - SheetDeleteFilterViewCondition, + // Object list (one read shortcut per object skill) + ChartList, + PivotList, + CondFormatList, + FilterList, + FilterViewList, + SparklineList, + FloatImageList, - // Dropdown - SheetSetDropdown, - SheetUpdateDropdown, - SheetGetDropdown, - SheetDeleteDropdown, + // Object CRUD (3 per skill) + ChartCreate, ChartUpdate, ChartDelete, + PivotCreate, PivotUpdate, PivotDelete, + CondFormatCreate, CondFormatUpdate, CondFormatDelete, + FilterCreate, FilterUpdate, FilterDelete, + FilterViewCreate, FilterViewUpdate, FilterViewDelete, + SparklineCreate, SparklineUpdate, SparklineDelete, + FloatImageCreate, FloatImageUpdate, FloatImageDelete, - // Float images - SheetMediaUpload, - SheetCreateFloatImage, - SheetUpdateFloatImage, - SheetGetFloatImage, - SheetListFloatImages, - SheetDeleteFloatImage, + // lark_sheet_batch_update + BatchUpdate, + CellsBatchSetStyle, + CellsBatchClear, + DropdownUpdate, + DropdownDelete, } } diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index eba8e2b30..0190018e9 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -1,343 +1,156 @@ --- name: lark-sheets -version: 1.2.0 -description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。" +version: 2.0.0 +description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。" metadata: requires: bins: ["lark-cli"] + siblings: ["lark-shared"] cliHelp: "lark-cli sheets --help" --- -# sheets (v3) +# sheets -**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。** + +## 术语约定 + +下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念: + +| 标准用语 | 同义 / 口语(均指同一对象) | 说明 | +| --- | --- | --- | +| 工作表(sheet) | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 | +| 电子表格(spreadsheet) | 工作簿、表格 | 顶层容器;由 `--url` 或 `--spreadsheet-token` 定位 | +| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image` 的 `--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id | -## 快速决策 -- 已知 spreadsheet URL / token 后,再进入 `sheets +info`、`sheets +read`、`sheets +find` 等对象内部操作。 +每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼): -## 核心概念 +| 对象 | 主键 flag | 对象 | 主键 flag | +| --- | --- | --- | --- | +| 工作表 sheet | `--sheet-id` | 条件格式规则 | `--rule-id` | +| 图表 chart | `--chart-id` | 筛选视图 | `--view-id` | +| 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` | +| 浮动图片 | `--float-image-id` | | | -### 文档类型与 Token +## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼) -飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`。 +把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。 -### 文档 URL 格式与 Token 处理 +| 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) | +| --- | --- | --- | +| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | — | +| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles`、`--with-merges`、`--include-merged-cells` | +| 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — | +| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — | +| 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` | +| 查找并替换 | `+cells-replace` | — | +| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` | +| 看工作簿 / 子表清单 | `+workbook-info` | — | +| 导出 xlsx / 单表 csv | `+workbook-export` | — | +| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `--type` | +| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `--target` | +| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag) | +| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 | -| URL 格式 | 示例 | Token 类型 | 处理方式 | -|----------|---------------------------------------------------------|-----------|----------| -| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | -| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | -| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` | -| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 | -| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 | +> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。 +> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。 -### Wiki 链接特殊处理(关键!) +## References -知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。 +本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。 -#### 处理流程 +### 通用方法与规范(先读,横切所有任务,不含具体 shortcut) -1. **使用 `wiki.spaces.get_node` 查询节点信息** - ```bash - lark-cli wiki spaces get_node --params '{"token":"wiki_token"}' - ``` +| Reference | 描述 | +| --- | --- | +| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 | +| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。仅针对飞书表格,不适用于本地 Excel 文件。 | +| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 | -2. **从返回结果中提取关键信息** - - `node.obj_type`:文档类型(docx/doc/sheet/bitable/slides/file/mindnote) - - `node.obj_token`:**真实的文档 token**(用于后续操作) - - `node.title`:文档标题 +### 按对象的工具参考(含 shortcut) -3. **根据 `obj_type` 使用对应的 API** +| Reference | 描述 | +| --- | --- | +| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 | +| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。仅针对飞书表格。 | +| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。仅针对飞书表格。 | +| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。仅针对飞书表格。 | +| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。仅针对飞书表格。 | +| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。仅针对飞书表格。 | +| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 | +| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 | +| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 | +| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。仅针对飞书表格。 | +| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 | +| [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。仅针对飞书表格。 | - | obj_type | 说明 | 使用的 API | - |----------|------|-----------| - | `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` | - | `doc` | 旧版云文档 | `drive file.comments.*` | - | `sheet` | 电子表格 | `sheets.*` | - | `bitable` | 多维表格 | `bitable.*` | - | `slides` | 幻灯片 | `drive.*` | - | `file` | 文件 | `drive.*` | - | `mindnote` | 思维导图 | `drive.*` | +## 公共 flag 速查 -#### 查询示例 +各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如: -```bash -# 查询 wiki 节点 -lark-cli wiki spaces get_node --params '{"token":"wiki_token"}' -``` - -返回结果示例: -```json -{ - "node": { - "obj_type": "docx", - "obj_token": "xxxx", - "title": "标题", - "node_type": "origin", - "space_id": "12345678910" - } -} -``` - -### 资源关系 - -``` -Wiki Space (知识空间) -└── Wiki Node (知识库节点) - ├── obj_type: docx (新版文档) - │ └── obj_token (真实文档 token) - ├── obj_type: doc (旧版文档) - │ └── obj_token (真实文档 token) - ├── obj_type: sheet (电子表格) - │ └── obj_token (真实文档 token) - ├── obj_type: bitable (多维表格) - │ └── obj_token (真实文档 token) - └── obj_type: file/slides/mindnote - └── obj_token (真实文档 token) - -Drive Folder (云空间/云盘/云存储文件夹) -└── File (文件/文档) - └── file_token (直接使用) -``` - -**操作流程(重要):** - -1. **create** — 创建筛选 - - 用于首次创建筛选 - - ⚠️ range 必须覆盖所有需要筛选的列(如 B1:E200) - - 如果已有筛选存在,再用 create 会覆盖整个筛选 - -2. **update** — 更新筛选 - - 用于在已有筛选上添加/更新指定列的条件 - - 只需指定 col 和 condition,不需要 range - -3. **delete** — 删除筛选 - -4. **get** — 获取筛选状态 - -**多列筛选示例:** +- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」),加 `--dry-run` +- `_公共:URL/token(无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token,常见于 `+batch-update` 等不强制 sheet 定位的 shortcut -创建媒体名称(B列)和情感分析(E列)的双重筛选: +徽章里只列名字。type / 必填 / 描述都在本段统一声明: -```bash -# 1. 删除现有筛选(如有) -lark-cli sheets spreadsheet.sheet.filters delete \ - --params '{"spreadsheet_token":"","sheet_id":""}' - -# 2. 创建第一个筛选,range 覆盖所有要筛选的列 -lark-cli sheets spreadsheet.sheet.filters create \ - --params '{"spreadsheet_token":"","sheet_id":""}' \ - --data '{"col":"B","condition":{"expected":["xx"],"filter_type":"multiValue"},"range":"!B1:E200"}' - -# 3. 添加第二个筛选条件 -lark-cli sheets spreadsheet.sheet.filters update \ - --params '{"spreadsheet_token":"","sheet_id":""}' \ - --data '{"col":"E","condition":{"expected":["xx"],"filter_type":"multiValue"}}' -``` - -**常见错误:** -- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create -- `Excess Limit`:update 时重复添加同一列条件 - -### 单元格数据类型 +### 公共 flag(定位资源) -接受二维数组的 shortcut(`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。 +**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"): -| 类型 | 写入格式 | 示例 | -|------|---------|------| -| 字符串 | `"文本"` | `"hello"` | -| 数字 | `数字` | `123`、`3.14` | -| 日期 | `数字`(自 1899-12-30 起的天数,需先设单元格日期格式) | `42101` | -| 链接(纯 URL) | `"URL 字符串"` | `"https://example.com"` | -| 链接(带文本) | `{"type":"url","text":"显示文本","link":"URL"}` | `{"type":"url","text":"飞书","link":"https://www.feishu.cn"}` | -| 邮箱 | `"邮箱字符串"` | `"user@example.com"` | -| **公式** | `{"type":"formula","text":"=公式"}` | `{"type":"formula","text":"=SUM(A1:A10)"}` | -| @人 | `{"type":"mention","text":"标识","textType":"email\|openId\|unionId","notify":false}` | `{"type":"mention","text":"user@example.com","textType":"email","notify":false}`(notify 可选,默认 false;仅在用户明确要求通知时设为 true) | -| @文档 | `{"type":"mention","textType":"fileToken","text":"token","objType":"类型"}` | `{"type":"mention","textType":"fileToken","text":"shtXXX","objType":"sheet"}` | -| 下拉列表 | `{"type":"multipleValue","values":[值1,值2]}` | `{"type":"multipleValue","values":["选项A","选项B"]}` | +1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。 + - **`--url` 只解析 `/sheets/` 与 `/spreadsheets/` 两种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。 + - ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token ""`,确认返回的 `obj_type` 为 `sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。 + - **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。 +2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。 + - ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。 + - ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。 + - ⚠️ **A1 reference 含 `!`**(`--source` / `--range` / `--ranges`)**:shell session 起手先 `set +H`** 关 bash history expansion,否则 `"Sheet1!A1"` 会被拦成 `event not found`;含特殊字符(`-` / 空格 / 非 ASCII)的 sheet 名还要内部 single-quote 包,如 `--source "'Sales-2025'!A1:D100"`。 + - **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。 -**写入公式示例**: +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--url` | string | 二选一必填(与 `--spreadsheet-token`) | spreadsheet URL | +| `--spreadsheet-token` | string | 二选一必填(与 `--url`) | spreadsheet token | +| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut) | 工作表 reference_id | +| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut) | 工作表名称 | + +**统一调用范式**(公共四件套 shortcut 的所有示例都遵循此形状,两组定位缺一不可): ```bash -# ✅ 正确:使用对象格式 -lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \ - --values '[[{"type":"formula","text":"=SUM(C2:C5)"}]]' - -# ❌ 错误:直接传字符串,会被存为纯文本 -lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \ - --values '[["=SUM(C2:C5)"]]' +lark-cli sheets <其它 flag> +# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给) +# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填) +# 例:lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30" +# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。 ``` -> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。 - -**限制**: -- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用) -- @人仅支持同租户用户,单次最多 50 人 -- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-dropdown.md#set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown)。值中的字符串不能包含逗号 - -## Shortcuts(推荐优先使用) - -Shortcut 是对常用操作的高级封装(`lark-cli sheets + [flags]`)。有 Shortcut 的操作优先使用。 - -### Spreadsheet Management - -对应参考文档:[spreadsheet-management](references/lark-sheets-spreadsheet-management.md) - -| Shortcut | 说明 | -|----------|------| -| [`+create`](references/lark-sheets-spreadsheet-management.md#create) | Create a spreadsheet (optional header row and initial data) | -| [`+info`](references/lark-sheets-spreadsheet-management.md#info) | View spreadsheet metadata and sheet information | -| [`+export`](references/lark-sheets-spreadsheet-management.md#export) | Export a spreadsheet (async task polling + optional download) | - -### Sheet Management - -对应参考文档:[sheet-management](references/lark-sheets-sheet-management.md) - -| Shortcut | 说明 | -|----------|------| -| [`+create-sheet`](references/lark-sheets-sheet-management.md#create-sheet) | Create a sheet in an existing spreadsheet | -| [`+copy-sheet`](references/lark-sheets-sheet-management.md#copy-sheet) | Copy a sheet within a spreadsheet | -| [`+delete-sheet`](references/lark-sheets-sheet-management.md#delete-sheet) | Delete a sheet from a spreadsheet | -| [`+update-sheet`](references/lark-sheets-sheet-management.md#update-sheet) | Update sheet title, position, visibility, freeze, or protection | +### 系统 flag -### Cell Data +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 | +| `--yes` | bool | 是(仅 `high-risk-write`) | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 | +| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name ` 搭配指定要查哪个 flag;省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 | +| `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 | -对应参考文档:[cell-data](references/lark-sheets-cell-data.md) +**Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets --print-schema --flag-name ` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。 -| Shortcut | 说明 | -|----------|------| -| [`+read`](references/lark-sheets-cell-data.md#read) | Read spreadsheet cell values | -| [`+write`](references/lark-sheets-cell-data.md#write) | Write to spreadsheet cells (overwrite mode) | -| [`+append`](references/lark-sheets-cell-data.md#append) | Append rows to a spreadsheet | -| [`+find`](references/lark-sheets-cell-data.md#find) | Find cells in a spreadsheet | -| [`+replace`](references/lark-sheets-cell-data.md#replace) | Find and replace cell values | +### flag 内容类型与输出约定(术语速记) -### Cell Style And Merge +- flag 表里 JSON 类入参标三类:**复合 JSON** = 深层嵌套对象(用 `--print-schema` 取完整结构);**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema);**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)。 +- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get` 的 `annotated_csv`);写操作不会自动回读,如需校验请自行调用对应的 `+*-list` / `+*-get` / `+cells-get`。 -对应参考文档:[cell-style-and-merge](references/lark-sheets-cell-style-and-merge.md) +## 复合 JSON / 大入参:优先 stdin -| Shortcut | 说明 | -|----------|------| -| [`+set-style`](references/lark-sheets-cell-style-and-merge.md#set-style) | Set cell style for a range | -| [`+batch-set-style`](references/lark-sheets-cell-style-and-merge.md#batch-set-style) | Batch set cell styles for multiple ranges | -| [`+merge-cells`](references/lark-sheets-cell-style-and-merge.md#merge-cells) | Merge cells in a spreadsheet | -| [`+unmerge-cells`](references/lark-sheets-cell-style-and-merge.md#unmerge-cells) | Unmerge (split) cells in a spreadsheet | +flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。 -### Cell Images - -对应参考文档:[cell-images](references/lark-sheets-cell-images.md) - -| Shortcut | 说明 | -|----------|------| -| [`+write-image`](references/lark-sheets-cell-images.md#write-image) | Write an image into a spreadsheet cell | - -### Row Column Management - -对应参考文档:[row-column-management](references/lark-sheets-row-column-management.md) - -| Shortcut | 说明 | -|----------|------| -| [`+add-dimension`](references/lark-sheets-row-column-management.md#add-dimension) | Add rows or columns at the end of a sheet | -| [`+insert-dimension`](references/lark-sheets-row-column-management.md#insert-dimension) | Insert rows or columns at a specified position | -| [`+update-dimension`](references/lark-sheets-row-column-management.md#update-dimension) | Update row or column properties (visibility, size) | -| [`+move-dimension`](references/lark-sheets-row-column-management.md#move-dimension) | Move rows or columns to a new position | -| [`+delete-dimension`](references/lark-sheets-row-column-management.md#delete-dimension) | Delete rows or columns | - -### Filter Views - -对应参考文档:[filter-views](references/lark-sheets-filter-views.md) - -| Shortcut | 说明 | -|----------|------| -| [`+create-filter-view`](references/lark-sheets-filter-views.md#create-filter-view) | Create a filter view | -| [`+update-filter-view`](references/lark-sheets-filter-views.md#update-filter-view) | Update a filter view | -| [`+list-filter-views`](references/lark-sheets-filter-views.md#list-filter-views) | List all filter views in a sheet | -| [`+get-filter-view`](references/lark-sheets-filter-views.md#get-filter-view) | Get a filter view by ID | -| [`+delete-filter-view`](references/lark-sheets-filter-views.md#delete-filter-view) | Delete a filter view | -| [`+create-filter-view-condition`](references/lark-sheets-filter-views.md#create-filter-view-condition) | Create a filter condition on a filter view | -| [`+update-filter-view-condition`](references/lark-sheets-filter-views.md#update-filter-view-condition) | Update a filter condition | -| [`+list-filter-view-conditions`](references/lark-sheets-filter-views.md#list-filter-view-conditions) | List all filter conditions of a filter view | -| [`+get-filter-view-condition`](references/lark-sheets-filter-views.md#get-filter-view-condition) | Get a filter condition by column | -| [`+delete-filter-view-condition`](references/lark-sheets-filter-views.md#delete-filter-view-condition) | Delete a filter condition | - -### Dropdown - -对应参考文档:[dropdown](references/lark-sheets-dropdown.md) - -| Shortcut | 说明 | -|----------|------| -| [`+set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown) | 设置下拉列表(`multipleValue` 写入的前置步骤) | -| [`+update-dropdown`](references/lark-sheets-dropdown.md#update-dropdown) | 更新下拉列表选项 | -| [`+get-dropdown`](references/lark-sheets-dropdown.md#get-dropdown) | 查询下拉列表配置 | -| [`+delete-dropdown`](references/lark-sheets-dropdown.md#delete-dropdown) | 删除下拉列表 | - -### Float Images - -对应参考文档:[float-images](references/lark-sheets-float-images.md) - -| Shortcut | 说明 | -|----------|------| -| [`+media-upload`](references/lark-sheets-float-images.md#media-upload) | 上传本地图片素材,返回 `file_token`(供 `+create-float-image` 使用;>20MB 自动分片) | -| [`+create-float-image`](references/lark-sheets-float-images.md#create-float-image) | 创建浮动图片 | -| [`+update-float-image`](references/lark-sheets-float-images.md#update-float-image) | 更新浮动图片属性 | -| [`+get-float-image`](references/lark-sheets-float-images.md#get-float-image) | 获取浮动图片 | -| [`+list-float-images`](references/lark-sheets-float-images.md#list-float-images) | 查询所有浮动图片 | -| [`+delete-float-image`](references/lark-sheets-float-images.md#delete-float-image) | 删除浮动图片 | - -### Formula - -对应参考文档:[formula](references/lark-sheets-formula.md) - -> 浮动图片相关的读接口只返回元数据(含 `float_image_token`),**不包含图片字节**。要读取图片内容,用 token 调 `lark-cli docs +media-preview --token "" --output ./image.png`。 - -## API Resources +推荐写法:payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去: ```bash -lark-cli schema sheets.. # 调用 API 前必须先查看参数结构 -lark-cli sheets [flags] # 调用 API +# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录) +lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE" ``` -> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。 - -### spreadsheets - - - `create` — 创建电子表格 - - `get` — 获取电子表格信息 - - `patch` — 修改电子表格属性 - -### spreadsheet.sheet.filters - - - `create` — 创建筛选 - - `delete` — 删除筛选 - - `get` — 获取筛选 - - `update` — 更新筛选 - -### spreadsheet.sheets - - - `find` — 查找单元格 - -### spreadsheet.sheet.float_images - - - `create` — 创建浮动图片 - - `patch` — 更新浮动图片 - - `get` — 获取浮动图片 - - `query` — 查询所有浮动图片 - - `delete` — 删除浮动图片 - -## 权限表 - -| 方法 | 所需 scope | -|------|-----------| -| `spreadsheets.create` | `sheets:spreadsheet:create` | -| `spreadsheets.get` | `sheets:spreadsheet.meta:read` | -| `spreadsheets.patch` | `sheets:spreadsheet.meta:write_only` | -| `spreadsheet.sheet.filters.create` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheet.filters.delete` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheet.filters.get` | `sheets:spreadsheet:read` | -| `spreadsheet.sheet.filters.update` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheets.find` | `sheets:spreadsheet:read` | -| `spreadsheet.sheet.float_images.create` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheet.float_images.patch` | `sheets:spreadsheet:write_only` | -| `spreadsheet.sheet.float_images.get` | `sheets:spreadsheet:read` | -| `spreadsheet.sheet.float_images.query` | `sheets:spreadsheet:read` | -| `spreadsheet.sheet.float_images.delete` | `sheets:spreadsheet:write_only` | +**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`-- - < 文件`)。 diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md new file mode 100644 index 000000000..b2880d891 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -0,0 +1,191 @@ +# Lark Sheet Batch Update + +## 写入边界 + 回读校验 + +`+batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束: + +1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。 +2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照。 +3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。 + +## 使用场景 + +写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 `+batch-update`。 + +**不可放进 `--operations` 的写 shortcut**(`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+dropdown-update` / `+dropdown-delete` / `+cells-batch-set-style` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。 + +**⚠️ 何时必须使用 `+batch-update`(硬性要求)**: +- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容) +- 需要对**多个**不同区域执行 `+rows-resize / +cols-resize` 时(如统一调整多列列宽或多行行高) +- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`) +- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合) + +当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 + +**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+batch-update` | high-risk-write | 批量 | +| `+cells-batch-set-style` | write | 批量 | +| `+dropdown-update` | write | 对象 | +| `+dropdown-delete` | high-risk-write | 对象 | +| `+cells-batch-clear` | high-risk-write | 批量 | + +## Flags + +### `+batch-update` + +_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON(如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name ;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认严格事务(首个失败即整批中断),传 --continue-on-error 切换为软批量(遇失败仍继续);不支持嵌套;按数组顺序串行执行 | +| `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) | + +### `+cells-batch-set-style` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A1:B2","'Sheet2'!D1:D10"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style | +| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) | +| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) | +| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) | +| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) | +| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) | +| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through`) | +| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right`) | +| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) | +| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) | +| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | +| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON(结构同 +cells-set-style) | + +### `+dropdown-update` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["'Sheet1'!A2:A100","'Sheet1'!C2:C100"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id | +| `--options` | string + File + Stdin(复合 JSON) | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 | +| `--multiple` | bool | optional | 启用多选 | +| `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | + +### `+dropdown-delete` + +_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["'Sheet1'!E2:E6"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id | + +### `+cells-batch-clear` + +_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A2:Z1000","'Sheet2'!A2:Z1000"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 | +| `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+batch-update` `--operations` + +_要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_ + +**数组项**(类型 object): +- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete] +- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 … + +### `+cells-batch-set-style` `--border-styles` + +_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_ + +**顶层字段**: +- `top` (object?) { style?: enum, weight?: enum, color?: string } +- `bottom` (object?) { style?: enum, weight?: enum, color?: string } +- `left` (object?) { style?: enum, weight?: enum, color?: string } +- `right` (object?) { style?: enum, weight?: enum, color?: string } + +### `+dropdown-update` `--options` + +_列表选项_ + +**数组项**(类型 string): +- 标量:string + +## Examples + +公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR;`+batch-update` 本身不强制 sheet-id,子操作各自携带)。 + +### `+batch-update` + +示例: + +```bash +lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --yes \ + --operations @ops.json + +# ops.json (array<{shortcut, input}>,shortcut 用 CLI 名): +# [ +# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}}, +# {"shortcut": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}} +# ] +``` + +> ⚠️ **子操作定位规则**: +> - spreadsheet 定位(`--url` / `--spreadsheet-token`)**只在顶层给一次**;`+batch-update` 顶层**没有** `--sheet-id` / `--sheet-name`,在顶层传不生效。 +> - **每个子操作的子表定位 `sheet_id`(或 `sheet_name`)写进它自己的 `input`**(见上方 ops.json 每个 item)。 +> - `input` 的键是该 shortcut 的 flag **展平**成 JSON(`"range":"A11:B12"`、`"dimension":"row"`),不要把整组 `--operations` 再套一层嵌套 JSON。 + +> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 +> +> ```jsonc +> // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行 +> [ +> {"shortcut": "+dim-insert", +> "input": {"sheet_id": "...", "dimension": "column", "start": 3, "end": 4}}, +> {"shortcut": "+cells-set", +> "input": {"sheet_id": "...", "range": "C1:C100", +> "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}} +> ] +> ``` + +### `+cells-batch-set-style` + +多 range 应用同一组 style(服务端走 `+batch-update` 原子事务): + +```bash +# 表头行 + 汇总行同时刷成蓝底白字 +lark-cli sheets +cells-batch-set-style --url "..." \ + --ranges '["sheet1!A1:F1","sheet1!A30:F30"]' \ + --background-color "#1E5BC6" --font-color "#FFFFFF" --font-weight bold +``` + +### `+cells-batch-clear` + +多 range 一次性清除(服务端走 `+batch-update` 原子事务);`--scope` 同 `+cells-clear`(`content` / `formats` / `all`,默认 `content`),`high-risk-write` 强制 `--yes`: + +```bash +# dry-run 先看清除范围 +lark-cli sheets +cells-batch-clear --url "..." \ + --ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --dry-run +# 执行 +lark-cli sheets +cells-batch-clear --url "..." \ + --ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --yes +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。`+cells-batch-clear` 的 `--ranges` 同样必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。 +- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。 +- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 `+batch-update` 的语义)。 diff --git a/skills/lark-sheets/references/lark-sheets-cell-data.md b/skills/lark-sheets/references/lark-sheets-cell-data.md deleted file mode 100644 index d8942f0f8..000000000 --- a/skills/lark-sheets/references/lark-sheets-cell-data.md +++ /dev/null @@ -1,197 +0,0 @@ -# Sheets Cell Data - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总单元格数据操作: - -- `+read` -- `+write` -- `+append` -- `+find` -- `+replace` - - -## `+read` - -对应命令:`lark-cli sheets +read` - -内置能力: - -- 支持 `--url` / `--spreadsheet-token` 二选一(URL 支持 wiki) -- 若已传 `--sheet-id`,`--range` 可写 `A1:D10` 或 `C2` -- 默认最多返回 200 行 - -```bash -lark-cli sheets +read --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --range "!A1:H20" - -lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "C2" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 否 | `!A1:D10`、`A1:D10` / `C2` 或 `` | -| `--sheet-id` | 否 | 工作表 ID | -| `--value-render-option` | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `range` -- `values` -- `truncated` -- `total_rows` - - -## `+write` - -对应命令:`lark-cli sheets +write` - -用于覆盖写入一个矩形区域。 - -```bash -lark-cli sheets +write --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:B2" \ - --values '[["name","age"],["alice",18]]' - -lark-cli sheets +write --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --range "C2" \ - --values '[["hello"]]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 否 | 写入范围;可用相对范围或 `` | -| `--sheet-id` | 否 | 工作表 ID | -| `--values` | 是 | 二维数组 JSON | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `updated_range` -- `updated_rows` -- `updated_columns` -- `updated_cells` -- `revision` - - -## `+append` - -对应命令:`lark-cli sheets +append` - -用于向工作表末尾追加行。 - -```bash -lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1" \ - --values '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 否 | 追加范围:支持 ``、完整范围、相对范围 | -| `--sheet-id` | 否 | 工作表 ID | -| `--values` | 是 | 二维数组 JSON | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `table_range` -- `updated_range` -- `updated_rows` -- `updated_columns` -- `updated_cells` -- `revision` - - -## `+find` - -对应命令:`lark-cli sheets +find` - -只在一个已知 spreadsheet 内查找单元格内容,不是云空间(云盘/云存储)搜索。 - -```bash -lark-cli sheets +find --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --find "张三" --range "A1:H200" - -lark-cli sheets +find --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --find "仓库管理营收报表" --ignore-case -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--find` | 是 | 查找内容 | -| `--range` | 否 | 范围;不填则搜索整个工作表 | -| `--ignore-case` | 否 | 不区分大小写 | -| `--match-entire-cell` | 否 | 完全匹配单元格 | -| `--search-by-regex` | 否 | 使用正则 | -| `--include-formulas` | 否 | 搜索公式 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `matched_cells` -- `matched_formula_cells` -- `rows_count` - - -## `+replace` - -对应命令:`lark-cli sheets +replace` - -在指定范围内查找并替换单元格内容。 - -```bash -lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --find "hello" --replacement "world" - -lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --find "\\d{4}-\\d{2}-\\d{2}" \ - --replacement "DATE" --search-by-regex -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--find` | 是 | 搜索文本 | -| `--replacement` | 是 | 替换文本 | -| `--range` | 否 | 搜索范围,不传则搜索整个工作表 | -| `--match-case` | 否 | 区分大小写 | -| `--match-entire-cell` | 否 | 匹配整个单元格 | -| `--search-by-regex` | 否 | 使用正则 | -| `--include-formulas` | 否 | 在公式中搜索 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `replace_result.matched_cells` -- `replace_result.matched_formula_cells` -- `replace_result.rows_count` - -## 参考 - -- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` -- [dropdown](lark-sheets-dropdown.md#set-dropdown) — 写入 `multipleValue` 前先设置下拉列表 -- [formula](lark-sheets-formula.md) — 公式写入规则 diff --git a/skills/lark-sheets/references/lark-sheets-cell-images.md b/skills/lark-sheets/references/lark-sheets-cell-images.md deleted file mode 100644 index b06c1b553..000000000 --- a/skills/lark-sheets/references/lark-sheets-cell-images.md +++ /dev/null @@ -1,59 +0,0 @@ -# Sheets Cell Images - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总单元格图片写入能力: - -- `+write-image` - - -## `+write-image` - -对应命令:`lark-cli sheets +write-image` - -特性: - -- 将本地图片文件写入到指定单元格 -- 支持格式:PNG、JPEG、JPG、GIF、BMP、JFIF、EXIF、TIFF、BPG、HEIC -- `--range` 必须表示单个单元格,如 `A1` 或 `!B2:B2` -- `--name` 默认取 `--image` 的文件名 - -```bash -# 写入图片到指定单元格 -lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ - --range "!B2:B2" \ - --image "./logo.png" - -# 使用 URL + sheet-id,指定单个单元格 -lark-cli sheets +write-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --range "C3" \ - --image "./chart.jpg" - -# 自定义图片名称 -lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:A1" \ - --image "./output.png" --name "revenue_chart.png" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 目标单元格:`!A1:A1` 或相对单元格 | -| `--sheet-id` | 否 | 工作表 ID | -| `--image` | 是 | 本地图片文件的相对路径 | -| `--name` | 否 | 图片文件名(默认取 `--image` 的文件名) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheetToken` -- `updateRange` -- `revision` - -## 参考 - -- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据 -- [float-images](lark-sheets-float-images.md) — 管理浮动图片 diff --git a/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md b/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md deleted file mode 100644 index 8ca135da5..000000000 --- a/skills/lark-sheets/references/lark-sheets-cell-style-and-merge.md +++ /dev/null @@ -1,141 +0,0 @@ -# Sheets Cell Style and Merge - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总单元格样式和合并相关操作: - -- `+set-style` -- `+batch-set-style` -- `+merge-cells` -- `+unmerge-cells` - - -## `+set-style` - -对应命令:`lark-cli sheets +set-style` - -对指定范围设置字体、颜色、对齐、边框等样式。 - -```bash -lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:C3" \ - --style '{"font":{"bold":true},"backColor":"#ff0000"}' - -lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:Z100" --style '{"clean":true}' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 单元格范围 | -| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | -| `--style` | 是 | 样式 JSON 对象 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -常用 `style` 字段: - -- `font.bold` -- `font.italic` -- `font.font_size` -- `textDecoration` -- `formatter` -- `hAlign` -- `vAlign` -- `foreColor` -- `backColor` -- `borderType` -- `borderColor` -- `clean` - -输出:`updates`(updatedRange / updatedRows / updatedColumns / updatedCells / revision) - - -## `+batch-set-style` - -对应命令:`lark-cli sheets +batch-set-style` - -对多个范围批量设置不同样式。 - -```bash -lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \ - --data '[{"ranges":["!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["!D1:F3"],"style":{"foreColor":"#ff0000"}}]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--data` | 是 | JSON 数组,每项包含 `ranges` 和 `style` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `totalUpdatedRows` -- `totalUpdatedColumns` -- `totalUpdatedCells` -- `revision` -- `responses[]` - - -## `+merge-cells` - -对应命令:`lark-cli sheets +merge-cells` - -支持三种模式: - -- `MERGE_ALL` -- `MERGE_ROWS` -- `MERGE_COLUMNS` - -```bash -lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:B2" --merge-type MERGE_ALL -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 单元格范围 | -| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | -| `--merge-type` | 是 | `MERGE_ALL` / `MERGE_ROWS` / `MERGE_COLUMNS` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`spreadsheetToken` - - -## `+unmerge-cells` - -对应命令:`lark-cli sheets +unmerge-cells` - -用于拆分合并单元格。 - -```bash -lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \ - --range "!A1:B2" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 单元格范围 | -| `--sheet-id` | 否 | 工作表 ID(用于相对范围) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`spreadsheetToken` - -## 参考 - -- [cell-data](lark-sheets-cell-data.md) — 数据读写 -- [cell-images](lark-sheets-cell-images.md) — 写入单元格图片 diff --git a/skills/lark-sheets/references/lark-sheets-chart.md b/skills/lark-sheets/references/lark-sheets-chart.md new file mode 100644 index 000000000..3c242f9ef --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-chart.md @@ -0,0 +1,319 @@ +# Lark Sheet Chart + +## 真对象硬约束 + +当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。 + +## 使用场景 + +读写图表对象。本 reference 覆盖 4 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有图表 | `+chart-list` | 获取图表的类型、数据源和样式配置 | +| 创建/更新/删除图表 | `+chart-{create|update|delete}` | 对图表对象执行写入操作 | + +典型工作流:先读取现有图表了解配置 → 执行创建/更新/删除 → 再次读取验证结果。 + +## 需求→图表类型映射(创建前必查) + +| 用户说 | 图表类型 | 备注 | +|--------|---------|------| +| "占比"、"比例"、"各XX占多少" | 饼图(pie) | 单维度占比首选 | +| "对比"、"各XX的YY" | 柱形图(column,纵向) | 多类别数值对比;横向条形用 `bar` | +| "趋势"、"变化"、"走势" | 折线图(line) | 时间序列首选 | +| "堆积"、"组成构成" | 堆积柱形图(column + stack) | 多系列累加 | +| "分布"、"相关性" | 散点图(scatter) | 两变量关系 | + +**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。 + +**`--properties` 结构锚点(构造前必读)**:`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。完整结构以 `lark-cli sheets +chart-create --print-schema --flag-name properties` 为准。 + +**常见配置错误(必须注意)**: +- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column` +- **数据标签缺失**:用户需要看到具体数值时,需配置 `properties.snapshot.plotArea.plot.labels`(数据标签)相关字段 +- **数据源范围与系列名来源要对齐**: + - **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。 + - **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。 + - **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `snapshot.data.headerMode='detached'`:refs 仅传纯数据范围,维度名/系列名通过 `snapshot.data.dim1.serie.nameRef` / `snapshot.data.dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。 +- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。 +- **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确 + +> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。** +> 例如用户说"横轴为车型系列,纵轴为Q1-Q4的销量",你不能猜测列索引,必须先通过读取表格数据源范围的首行内容(使用 `lark-sheets-read-data` 的 `+cells-get` 或其他读取单元格的工具),确认"车型系列"是第几列、"Q1"~"Q4"分别是第几列,然后再将正确的列索引填入 `dim1.serie.index` 和 `dim2.series[].index`。 + +> **⚠️ 硬性规则:数据与表头分离场景必须使用 detached 模式。** 当 `refs` 仅覆盖数据的一个子集,而真正的语义表头行/列位于该子集之外时,**必须** `snapshot.data.headerMode='detached'` 并配上 `nameRef`。不能用 inline 模式 + 把 refs 多带 1 行兜底表头来替代——那种写法已废弃。否则图表会把错误的首行/首列当系列名,或图例显示成"系列1/系列2"等默认名,或者 refs 里混入相邻分组的数据。 +> +> **触发该规则的典型信号**(满足任意一条都必须走 detached): +> - 用户要求"针对 X 类的数据画图"、"只看某个分组"、"只画筛选后的部分",而 X 类对应的行段在数据中间或末尾,与表头不连续; +> - 用户要求"按 X 分别画图"、"按某个维度(部门/品类/地区/时间段等)拆图"——**多张图共享同一组表头**; +> - `refs` 起始行 > 表头行(如表头在第 1 行,但 `refs` 从第 11 行开始); +> - `refs` 起始列 > 表头列(如表头在 A 列,但 `refs` 从 C 列开始)。 +> +> **正确做法**: +> 1. 在 `data` 下显式设置 `"headerMode": "detached"`; +> 2. `refs` **只覆盖该子集的纯数据**,不要向上/向左多带 1 行/列,也不要把全局表头整段并进来(否则会把其它分组的数据混进图); +> 3. **`nameRef` 必填**:给 `dim1.serie.nameRef` 写真正表头中"类别名"那一格的 A1 引用(如 `'Sheet2'!A1`,sheet 名按 A1 标准单引号包裹),给每个 `dim2.series[i].nameRef` 写对应数值列的 A1 引用(如 `'Sheet2'!C1`、`'Sheet2'!D1`)。任一缺失会被校验拦下并报 `headerMode=detached requires ... nameRef`; +> 4. `refs[i].value` 必须是单元格或普通矩形范围(CELL / NORMAL),不接受整行/整列/开区间;`direction='column'` 时起始行必须 > 0,`direction='row'` 时起始列必须 > 0; +> 5. `index` 仍按 `refs` 内的列/行号填,从 1 开始。 +> +> **两种场景对照(互斥,二选一)**: +> +> | 场景 | 何时命中 | 写法 | +> |---|---|---| +> | A. 表头与数据连在一起 | 单张图、refs 首行/首列就是表头(典型整段画图) | **省略 headerMode**(默认 inline),refs 含表头,**不写 nameRef** | +> | B. 表头与数据分离 | 上面 4 条信号任一命中(数据子集、按维度拆图等) | **`headerMode='detached'`**,refs 仅纯数据,**`nameRef` 必填** | +> +> **反向约束**:场景 A 下不要写 `nameRef`——首行命名已经生效,多写反而冗余。`nameRef` 仅在场景 B 下使用(且必填)。 + +## ⚠️ chart 数据源引用 pivot 时必须排除总计行(高频致命错误) + +当 chart 要基于刚创建的 pivot 产物画图时,**禁止凭猜写 `refs`**。pivot 默认启用 `show_row_grand_total` / `show_col_grand_total`,产物最后一行/一列通常是"总计"。如果 `refs` 把总计行一并框进去: +- **柱形图**末尾会多一根天文数字柱子(=所有数据求和),把其他柱子压扁到看不见 +- **饼图**会多一个"总计"扇区占 33%+,真实类别的比例完全失真 + +**正确流程**: +1. `+pivot-create create` 返回 `sheet_id` + `pivot_table_id` +2. 调 `+csv-get(sheet_id, 'A1:E30')` 或 `+pivot-list` 读 pivot 产物的**实际数据范围** +3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计) +4. `+chart-create create` 时 `snapshot.data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`) + +## 图表位置选择(创建前必做) + +凭感觉挑列号/行号会被 API 拒(`position is out of sheet range`)。按以下四步走: + +1. **查尺寸**:`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount;`+sheet-info` 只返回布局,不含行列总数)。 +2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**,`needCols = ceil(width/105)`,`needRows = ceil(height/27)`。 +3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(col 按 A=0、B=1、…、Z=25、AA=26… 换算)。 +4. **不够就先扩表**,二选一,禁止硬塞越界位置: + - **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`; + - 否则先调 `+dim-insert`(`lark-sheets-sheet-structure`)扩行/列,再 create。 + +**示例**:21 列 sheet 放 600×400 图 → `needCols=6, needRows=15` +- ❌ `{row: 0, col: "W"}` — col=22 越界 +- ✅ `{row: 42, col: "A"}` — 放数据下方 +- ✅ 先 `+dim-insert --dimension column --start 21 --end 27`(在 U 列后插 6 列;U=index 20,after 即从 21 起),再放图到 `{row: 0, col: "V"}` + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+chart-list` | read | 对象 | +| `+chart-create` | write | 对象 | +| `+chart-update` | write | 对象 | +| `+chart-delete` | high-risk-write | 对象 | + +## Flags + +### `+chart-list` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--chart-id` | string | optional | 指定单个图表 reference_id 过滤 | + +### `+chart-create` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`)。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` | + +### `+chart-update` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--chart-id` | string | required | 目标图表 reference_id | +| `--properties` | string + File + Stdin(复合 JSON) | required | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) | + +### `+chart-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--chart-id` | string | required | 目标图表 reference_id | + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+chart-create` `--properties` / `+chart-update` `--properties` + +_创建/更新的图表属性_ + +**顶层字段**: +- `position` (object) — 必填 { row: number, col: string } +- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number } +- `size` (object) — 必填 { width: number, height: number } +- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 } + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则同 `+csv-get`)。 + +### `+chart-list` + +输出契约:返回按工作表分组的图表列表,每个图表含 `chart_id` / `position` / `details.snapshot` 等。 + +### `+chart-create` + +> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**(1-based,对应 `refs.value` 范围内的列序)。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。 + +最小可用列图(inline 模式:refs 含表头行): + +```bash +lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --properties '{ + "position":{"row":42,"col":"A"}, + "size":{"width":600,"height":400}, + "snapshot":{ + "data":{ + "refs":[{"value":"'Sheet1'!A1:B10"}], + "dim1":{"serie":{"index":1}}, + "dim2":{"series":[{"index":2}]} + }, + "plotArea":{"plot":{"type":"column"}} + } + }' + +# 走文件(推荐配置较多时) +lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @chart-config.json +``` + +**饼图专属示例**(`sectors` 必须嵌在 `plotArea.plot.series[i].sectors.sector[]`,且 `sector[].index` 1-based): + +饼图比 column / bar 更复杂:`sectors` 是 object,里面再包一个**单数** `sector` 数组——CLI 不替你 normalize,写错路径会被 server schema 直接拒。 + +```bash +lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{ + "position":{"row":24,"col":"F"}, + "size":{"width":600,"height":450}, + "snapshot":{ + "title":{"text":"各部门员工人数占比"}, + "plotArea":{"plot":{ + "type":"pie", + "series":[{ + "index":1, + "sectors":{"sector":[{"index":1,"offsetRadius":0.05}]} + }] + }}, + "data":{ + "refs":[{"value":"'Sheet1'!A1:B11"}], + "dim1":{"serie":{"index":1,"aggregate":true}}, + "dim2":{"series":[{"index":2,"aggregateType":"sum"}]} + } + } +}' +``` + +**数据与表头分离(必须用 `detached` + `nameRef`)**: + +场景:周度销量明细表,真实表头在第 1 行(A1=周次、C1=订单量、D1=退款量),数据按 B 列"店铺"分段;用户只要"3 号店"那一段(第 11–17 行)。 + +```bash +lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{ + "position":{"row":7,"col":"F"}, + "size":{"width":600,"height":360}, + "snapshot":{ + "title":{"text":"3 号店周度订单/退款"}, + "plotArea":{"plot":{"type":"column"}}, + "data":{ + "headerMode":"detached", + "direction":"column", + "refs":[{"value":"'Sheet2'!A11:D17"}], + "dim1":{"serie":{"index":1,"nameRef":"'Sheet2'!A1"}}, + "dim2":{"series":[ + {"index":3,"nameRef":"'Sheet2'!C1"}, + {"index":4,"nameRef":"'Sheet2'!D1"} + ]} + } + } +}' +``` + +约束: +- `refs` 只覆盖纯数据 `A11:D17`,**不要**把表头行 A1 并进来 +- `nameRef` 在 detached 模式下**必填**,缺了被校验报 `headerMode=detached requires ... nameRef` +- `index` 按 refs 内的列序算(A=1、B=2、C=3、D=4),**不是**全表列号 +- `nameRef` 必须配对应的 `index`;单写 `nameRef` 不传 `index` 直接报参数错 + +**多张图共享同一组表头(按维度拆图,必须用 detached)**: + +场景:销售明细表头在 A1:E1(月份/区域/销售额/订单数/客单价),数据按区域分 3 段(华北 A2:E9、华东 A10:E17、华南 A18:E25),要分别画 3 张图。 + +❌ 常见错误: + +```jsonc +// 错误 1:refs 含全局表头但跨段 —— 多个区域被混进同一张图 +{"data":{"refs":[{"value":"'Sheet'!A1:E17"}], ... }} // 华东图混进华北 8 行 +// 错误 2:inline + refs 只取数据段、不写 detached/nameRef —— 图例显示成具体数据值 +{"data":{"refs":[{"value":"'Sheet'!A10:E17"}],"dim1":{"serie":{"index":1}}, ... }} +``` + +✅ 正确模式:3 张图各自 detached、refs 干净不重叠: + +```jsonc +// 图 1:华北 +{"data":{ + "headerMode":"detached","direction":"column", + "refs":[{"value":"'Sheet'!A2:E9"}], + "dim1":{"serie":{"index":1,"nameRef":"'Sheet'!A1"}}, + "dim2":{"series":[ + {"index":3,"nameRef":"'Sheet'!C1"}, + {"index":4,"nameRef":"'Sheet'!D1"} + ]} +}} +// 图 2:华东 —— refs 改 'Sheet'!A10:E17,其余同上 +// 图 3:华南 —— refs 改 'Sheet'!A18:E25,其余同上 +``` + +> `--properties` JSON 关键字段: +> - `position.row` / `position.col` 必须留足空间,越界会被 API 拒(按本文件"图表位置选择"四步走) +> - `snapshot.data.headerMode`:默认 inline;当 refs 仅覆盖数据子集而语义表头在子集之外,必须 `detached` + `nameRef` +> - chart 引用 pivot 输出时,`snapshot.data.refs` 必须排除总计 / 小计行 + +### `+chart-update` + +**Update 三步法**(缺一步会丢字段): + +1. `+chart-list --chart-id ` 拿到完整 snapshot +2. 在拿到的 snapshot 上**局部**修改要改的字段,其余保持不变 +3. 把**完整 snapshot** 整个回写到 `--properties.snapshot` + +```bash +lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \ + --properties '{ + "position":{"row":0,"col":"A"}, + "size":{"width":480,"height":320}, + "snapshot": <完整快照(由 +chart-list 取回后局部修改)> + }' +``` + +> 关键:**不能只提交局部 snapshot**,否则未传字段会被还原为默认值。`+chart-update` 的语义是 PUT(整体覆盖),不是 PATCH。 + +### `+chart-delete` + +示例: + +```bash +# dry-run 先看会删什么(sheet 定位必填) +lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \ + --chart-id "chrXXX" --dry-run + +# 真正执行 +lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \ + --chart-id "chrXXX" --yes +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+chart-create` / `+chart-update` 的 `--properties` 必须能解析为合法 JSON;`+chart-delete`(high-risk-write)校验 `--yes` 或 `--dry-run` 至少一个。 +- `DryRun`:`+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板";`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。 +- `Execute`:写操作执行后不自动回读;如需确认,自行调用 `+chart-list` 比对结果。 + +> `+chart-create` / `+chart-update` 是 write 级别,按需可用 `--dry-run` 预览,不要求 `--yes`。只有 `+chart-delete`(high-risk-write)必须 `--yes`。 diff --git a/skills/lark-sheets/references/lark-sheets-conditional-format.md b/skills/lark-sheets/references/lark-sheets-conditional-format.md new file mode 100644 index 000000000..9d58ea09b --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-conditional-format.md @@ -0,0 +1,179 @@ +# Lark Sheet Conditional Format + +## 真对象硬约束 + 触发词清单 + +用户出现以下口语指令时,**强制**走 `+cond-format-{create|update|delete}`,**禁止**用 `+cells-set` 写静态背景色 / 字体色代替: + +- **颜色动作**:"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色 / 表红色 / 表黄色" +- **视觉强调**:"高亮 / 突出 / 标记 / 标注 / 区分" +- **条件触发**:"重复的标出来 / 异常的圈出来 / 过期的染红 / 大于 X 的标黄 / 不达标的标红" +- **联动语义**:"颜色随数据变 / 联动 / 自动更新 / 改了数据颜色也跟着变" +- **数值可视化**:"数据条 / 色阶 / 渐变色 / 进度条样式" + +飞书表格的"颜色标记"语义 = 条件格式规则 ≠ 静态背景色。如果用 `+cells-set` 写静态,源数据变化时颜色不会跟着变(典型反例:用户要求"过期单元格标红"时,模型用静态填充——日期变化后单元格颜色不再准确反映过期状态)。 + +**判断标准**:交付后 `+cond-format-list` 必须能返回该规则;否则视为违规。 + +**大数据量首选**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,比"本地脚本逐行计算 + `+cells-set` 写静态背景色"更高效、更稳(颜色还能随源数据自动联动)。 + +## 使用场景 + +读写条件格式对象。本 reference 覆盖 4 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有条件格式 | `+cond-format-list` | 获取规则类型、范围和样式配置 | +| 创建/更新/删除条件格式 | `+cond-format-{create|update|delete}` | 对条件格式规则执行写入操作 | + +典型工作流:先读取现有条件格式了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +**常见配置错误(必须注意)**: +- **创建后必须验证**:条件格式创建后必须调用 `+cond-format-list` 验证规则是否生效。如果验证发现规则未生效或配置不正确,应立即修复并重试 +- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏 +- **`style.back_color` vs `style.fore_color` 的中文语义**:用户中文语境下的"**标红/高亮/染色/标记**"指**单元格背景色**,用 `back_color`;"**文字红/字体红/把字变红**"才用 `fore_color`。默认无说明时选 `back_color`。把过期数据涂红、重复值高亮等都应该是 `back_color: "#FFE6E6"`(或类似浅红)配合可选的 `fore_color` 加深字体 +- **日期/空值比较必须防空**:用户说"过期的标红"时,除了 `TODAY()`,公式必须排除空单元格,否则空白格也会被误判为"早于今天"而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期) +- **公式条件注意引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 而非 `=$E$1<=TODAY()`,后者只比较一个格) + +⚠️ **用户明确要求"辅助列+条件格式"两步走时,禁止用 `expression` 绕过(高频致命错误)**:当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `rule_type: "expression"` 公式一步完成: + +- "**增加辅助列**,再/然后标记……" +- "**先计算/判断** XX **是否** YY,**再**标记……" +- "**新建一列**放结果,再用结果染色" +- 明确要求用 "辅助列"、"辅助字段"、"判断列"、"标记列" + +**正确做法(两步走)**: + +``` +Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列) + range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100" + +Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 expression) + `+cond-format-{create|update|delete}` create + rule_type: "expression" + ranges: ["A2:H100"] // 整行高亮 + attrs: [{formula: ["=$H2=\"是\""]}] // 引用辅助列 + style: {back_color: "#FFECEC"} +``` + +**错误做法(一步走绕过辅助列)**: + +``` +`+cond-format-{create|update|delete}` create + rule_type: "expression" + ranges: ["2:145"] + attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 不满足用户明确要求的"辅助列"诉求 +``` + +为什么禁止一步走:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到"是/否"列;条件格式只是视觉辅助。一步 expression 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整/未采用公式"。 + +`expression` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。 + +⚠️ **创建条件格式前必须读数据行确认列对应**:仅读首行表头(`+csv-get range="A1:Z1"`)不够——如果表头语义含糊(比如"时间"、"日期"这种多列同义词),formula 里引用的列字母可能张冠李戴。必须再读 3-5 行**数据样本**(如 `range="A2:Z6"`)确认:①列名对应的实际值;②字段含义匹配用户描述;③数据类型是日期/数字/文本。特别是比较类条件格式(`=$A2>$B2` 这种),列字母选错整条规则就废了。 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cond-format-list` | read | 对象 | +| `+cond-format-create` | write | 对象 | +| `+cond-format-update` | write | 对象 | +| `+cond-format-delete` | high-risk-write | 对象 | + +## Flags + +### `+cond-format-list` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--rule-id` | string | optional | 按规则 id 过滤 | + +### `+cond-format-create` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 `rule_type` 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag | +| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet`) | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | + +### `+cond-format-update` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--rule-id` | string | required | 目标规则 id | +| `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,结构同 `+cond-format-create` 的 `--properties`;update 是整组覆盖式 | +| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet`) | +| `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 | + +### `+cond-format-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--rule-id` | string | required | 目标规则 id | + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+cond-format-create` `--properties` / `+cond-format-update` `--properties` + +_创建/更新的条件格式属性_ + +**顶层字段**: +- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / colorScale / rank / aboveAverage / expression / iconSet] — ⚠️ 已拎为独立 flag `--rule-type`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `ranges` (array) — 应用条件格式的 A1 范围列表 — ⚠️ 已拎为独立 flag `--ranges`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, fore_color?: string, text_decoration?: enum, font?: enum } +- `attrs` (array?) — 规则参数列表 +- `has_ref` (boolean?) — 可选 + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 + +### `+cond-format-list` + +```bash +# 列出当前 sheet 全部条件格式规则(拿 rule_id 供 update/delete) +lark-cli sheets +cond-format-list --url "..." --sheet-id "$SID" +``` + +### `+cond-format-create` + +`--rule-type` / `--ranges` 是独立 flag(不要再放 `--properties`);`style` / `attrs` 等结构走 `--properties`: + +```bash +# 重复值高亮 +lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ + --rule-type duplicateValues --ranges '["A1:A100"]' \ + --properties '{"style":{"back_color":"#FFD7D7"}}' + +# 数据条 +lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \ + --rule-type dataBar --ranges '["B2:B100"]' \ + --properties @rule.json +``` + +### `+cond-format-update` + +整组覆盖式:先 `+cond-format-list --rule-id ` 拿当前完整配置,改后整组传回。 + +### `+cond-format-delete` + +```bash +lark-cli sheets +cond-format-delete --url "..." --sheet-id "$SID" --rule-id "$RULE_ID" --yes +``` + +> 一次只删一个 `--rule-id`。要删**多个**条件格式时,先 `+cond-format-list` 拿到各 `rule-id`,再用 `+batch-update` 把多个 `+cond-format-delete` 合并为单次原子提交,不要逐个调用。 + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellIs` 需 `attrs.operator` + `attrs.value`、`expression` 需 `attrs.formula`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 conditional_format 请求模板"。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+cond-format-list --rule-id ` 比对规则 / 范围 / 样式。 diff --git a/skills/lark-sheets/references/lark-sheets-core-operations.md b/skills/lark-sheets/references/lark-sheets-core-operations.md new file mode 100644 index 000000000..c7c915c48 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-core-operations.md @@ -0,0 +1,102 @@ +# 飞书表格核心操作:分析、编辑与可视化 + +## 概览 + +面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill,本文用指针引到那里,不重复展开。 + +**三份「通用方法与规范」如何分工**(都不含 shortcut,按主题单一归属): + +- **本文(core-operations)= 流程与铁律**:端到端工作流 + 全局铁律 + 横切陷阱,是读取入口与枢纽。 +- **`lark-sheets-visual-standards` = 样式知识**:配色 / 表头 / 数值格式 / 斑马纹 / 美化决策等"正确视觉输出"的全部标准。 +- **`lark-sheets-formula-translation` = 公式知识**:飞书公式书写与 Excel 迁移的全部正确性规则(绝对引用、范围语法、数组语义、不支持函数等)。 + +> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。 + +## 铁律(所有编辑类任务必须满足,子 skill 不得放宽) + +1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。 +2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。 +3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾(高频致命错误)。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。 +4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具(SORT / `TEXTBEFORE` / `MID` / 透视表 等)。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。 +5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。 +6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set),必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`。 +7. **分组汇总必须用透视表**:"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。 +8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert;多目标(删 N 行)每目标一个;多格式兼容(多种日期格式)每种至少一个样本;范围类(A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。 +9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。 + +## 推荐工作流程 + +1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill(避免读一个调一个),本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要。 +2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure` 的 `+sheet-info`。 +3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`)**: + + | 用户需求语义 | 路径 | + |---|---| + | "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A:原生优先**(公式 / `+pivot` / `+filter`,见第 5 步);原生表达不了或更复杂时**分批 `+csv-get` 导出 + 本地脚本处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准;脚本与 CLI 配合见下方「CLI 配合要点」) | + | "查一下 / 看看 / 统计 / 汇总" 等只读 | B:`+csv-get` 读到上下文 | + | 需要公式 / 样式 / 批注 | C:`+cells-get` | + | 续写 / 扩展 / 完善已有内容 | D:`+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见铁律 5) | + + **【高频致命错误】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就写入,实测会漏写表尾多行。写入前必须按 `lark-sheets-read-data`「确定数据范围的正确流程」确认真实数据末行。按关键字定位区域用 `lark-sheets-search-replace` 的 `+cells-search`。 + +4. **理解数据语义(写入前必做)**:读表头 + 3-5 行样本确认各列含义与格式(文本 / 数字 / 日期 / 混合);写公式前先分析样本值格式模式再选提取策略;建透视表前先列清"行字段=分组维度、值字段=聚合指标"。需求模糊时(如"加入加减乘除"未说逻辑)基于表头与已有公式推断,不确定就问用户,禁止臆造业务逻辑。 + +5. **分析与计算(原生工具优先,代码兜底)**:飞书原生能力能随数据自动更新,**必须优先**: + + | 用户需求 | 必须用的原生工具 | 禁止用代码替代 | + |---|---|---| + | 按 X 统计 Y、分组汇总 | `+pivot-{create\|update\|delete}` | pandas groupby → `+cells-set` | + | 求和 / 计数 / 平均 / 占比 | 公式(SUM/COUNT/AVERAGE) | Python 算 → 写静态值 | + | 画图表 / 可视化 | `+chart-{create\|update\|delete}` | matplotlib 画图 | + | 条件高亮 / 色阶 | `+cond-format-{create\|update\|delete}` | 逐单元格设样式 | + | 数据筛选 | `+filter-{create\|update\|delete}` | pandas filter → 覆盖写入 | + | 文本提取 / 转换 | 公式(REGEXEXTRACT/TEXT/VALUE) | Python 正则 → 写静态值 | + | 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 | + + **只有以下才用代码**:多步清洗流水线、统计建模、公式试错 3 次仍失败的降级。代码结果回写:大块纯值用 `+csv-put`(+ `--start-cell`,必要时自动扩容);少量或需公式 / 样式用 `+cells-set`;能用飞书公式表达的写飞书公式。 + +6. **写入与修改(细节见 `lark-sheets-write-cells`)**:`+cells-set` 的 `range` 必须落在已有行列范围内、`cells` 二维数组与 `range` 严格同维;表尾追加先用 `+dim-insert` 插行列再写;整列 / 整行同结构的值 / 公式 / 格式用模板单元格 + `--copy-to-range`,禁止逐行 `+cells-set`;多步写入合并为 `+batch-update`;改尺寸先读相邻可见行列当前尺寸再决定 `pixel` / `standard` / `auto`,不要猜数值。 + +7. **验证**:重新读取受影响区域确认值 / 公式 / 样式 / 批注符合预期;对象类(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)重新读对象配置确认;出错先定位错误类型 / 受影响区域 / 根因再修复重验。 + +## 用本地代码 / 脚本时的 CLI 配合要点 + +复杂处理——多步清洗、统计建模、批量转换、语义任务的分批编排等——用代码(`python` / `node` 等)解决是完全正当的。原生能力(公式 / `+pivot` / `+filter`)能表达就优先用(可随源数据自动重算);原生表达不了或逻辑更复杂时,放手用代码。下面几条让脚本与 CLI 顺畅配合: + +- **解析输出时只读 stdout**:CLI 把数据 JSON 写到 stdout、把诊断与警告写到 stderr。解析 JSON 时**不要合并这两条流**(即不要 `2>&1`),否则警告行混进 JSON 会让解析失败。用管道(`lark-cli … | jq …`)或先把 stdout 单独重定向到文件再读;需要诊断信息时把 stderr 另导到一个文件。 +- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**:BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`。 +- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。 +- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。 + +## 公式策略 + +- **公式优先于硬编码**(同铁律 4):能用公式表达的计算一律写公式,源数据变化才能自动重算。 +- **写任何公式前先读 `lark-sheets-formula-translation`**:它是公式正确性的唯一权威,覆盖绝对引用(`$`)、飞书范围语法(`H:H` 与工具 A1 表示法的区别)、ARRAYFORMULA / 数组语义、Excel 迁移、不支持函数清单等全部规则。本文不再单列这些细则。 + +## 常见陷阱(铁律已覆盖的不再重复,仅列易漏点) + +- **合并单元格**:合并区只有左上角存数据,其余读为空是正常行为;写入只能写左上角,写其它位置会报 `cell ... is inside a merged region`。改合并区先取消再操作。安全操作 5 条与"批量取消用大 range 一次调用"见 `lark-sheets-range-operations`。 +- **`+dim-insert` 不继承行高**:`--inherit-style before/after` 只继承值 / 公式 / 边框,不继承 `row_height`,新行会回落默认高度截断长文本;中间插行填文本前先读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。 +- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首 5 + 末 5 行查 `#VALUE!` / `#NAME?` / `#REF!` / `#DIV/0!`;同一方案试错上限 3 次,超了改代码以值写入。 +- **循环引用**:聚合公式(SUM/AVERAGE)引用范围不能含目标 cell 自身或其传递依赖。 +- **NaN / 空值 / 除零**:空值不直接参与运算;除法用 `IF` / `IFERROR` 防零。 +- **排序 / 筛选混合文本列**:带货币符 / 单位 / 表达式的文本列直接排序 / 筛选会按字典序出错,先抽数值到辅助列再处理(细则见 `lark-sheets-range-operations` / `lark-sheets-filter`)。 +- **隐藏行列**:`+csv-get` 默认 `--skip-hidden=false`(含隐藏行列);设 `true` 只看可见数据,但返回行序号与实际行号不再对应。 +- **行号一律取 `[row=N]` 前缀**:`+csv-get` 的 CSV 中双引号内换行是单元格内换行不是新行;禁止数 `\n`、禁止用"序号列"当行号(细则见 `lark-sheets-read-data`)。 +- **列字母取 `col_indices[j]`**:禁止手数表头逗号定位列(>10 列极易 off-by-one)。 +- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。 +- **`+cells-search` 不是万能**:用户说"汇总金额"是操作动作(求和),不是搜索该文本;只在确需定位某文本位置时才用。 + +## 特殊场景 + +### 续写 / 复制已有区块格式 + +核心要求见铁律 5。机制(带齐哪些样式字段、怎么采样写入)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」;样式标准(斑马纹奇偶 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。本文不再展开。 + +### NLP 任务处理 + +任务涉及语义理解、翻译、改写、摘要、分类、抽取、多行聚合时,以 NLP 方式处理,不要用纯规则代码替代语义理解(但可用代码做分批、行号映射、结果拼装与写回)。数据量大时**必须**分批(通常 30 行一批),每批处理完立即写回,不要全处理完再一次写入;单批生成通常不超 300 行,超出时按性质抽样或分批并向用户说明范围;多批写入优先用 `+batch-update` 合并为原子提交。 + +### 格式处理优先公式 + +"去除多余零 / 提取数字 / 文本格式转换 / 日期格式化"等清洗,**必须优先用公式**(`SUBSTITUTE` / `TEXT` / `VALUE` / `LEFT` / `RIGHT` / `MID` 等):写一个模板 + `--copy-to-range` 即可整列处理,远比逐行修改高效。 diff --git a/skills/lark-sheets/references/lark-sheets-dropdown.md b/skills/lark-sheets/references/lark-sheets-dropdown.md deleted file mode 100644 index 2086b9621..000000000 --- a/skills/lark-sheets/references/lark-sheets-dropdown.md +++ /dev/null @@ -1,133 +0,0 @@ -# Sheets Dropdown - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总下拉列表配置: - -- `+set-dropdown` -- `+update-dropdown` -- `+get-dropdown` -- `+delete-dropdown` - -> **关键规则:** 使用 `multipleValue` 写入前,必须先设置下拉列表;否则值会被当成纯文本。 - - -## `+set-dropdown` - -对应命令:`lark-cli sheets +set-dropdown` - -```bash -lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --range "!A2:A100" --condition-values '["选项1", "选项2", "选项3"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 范围(如 `!A2:A100`) | -| `--condition-values` | 是 | 下拉选项 JSON 数组 | -| `--multiple` | 否 | 是否多选 | -| `--highlight` | 否 | 是否着色 | -| `--colors` | 否 | 颜色 JSON 数组 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`code`、`msg` - - -## `+update-dropdown` - -对应命令:`lark-cli sheets +update-dropdown` - -```bash -lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" \ - --ranges '["!A1:A100"]' \ - --condition-values '["选项A", "选项B"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--ranges` | 是 | 范围 JSON 数组 | -| `--condition-values` | 是 | 选项 JSON 数组 | -| `--multiple` | 否 | 是否多选 | -| `--highlight` | 否 | 是否着色 | -| `--colors` | 否 | 颜色 JSON 数组 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`spreadsheetToken`、`sheetId`、`dataValidation` - - -## `+get-dropdown` - -对应命令:`lark-cli sheets +get-dropdown` - -```bash -lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --range "!A2:A100" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--range` | 是 | 查询范围 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `dataValidations[].conditionValues` -- `dataValidations[].ranges` -- `dataValidations[].options.multipleValues` -- `dataValidations[].options.highlightValidData` -- `dataValidations[].options.colorValueMap` - - -## `+delete-dropdown` - -对应命令:`lark-cli sheets +delete-dropdown` - -```bash -lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \ - --ranges '["!A2:A100", "!C1:C50"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--ranges` | 是 | 范围 JSON 数组 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `rangeResults[].range` -- `rangeResults[].success` -- `rangeResults[].updatedCells` - -## 典型流程 - -```bash -# 1. 配置下拉 -lark-cli sheets +set-dropdown --url "" \ - --range "!J2:J100" --condition-values '["选项1","选项2"]' --multiple - -# 2. 再写入 multipleValue -lark-cli sheets +write --url "" --sheet-id "" --range "J2" \ - --values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]' -``` - -## 参考 - -- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据 diff --git a/skills/lark-sheets/references/lark-sheets-filter-view.md b/skills/lark-sheets/references/lark-sheets-filter-view.md new file mode 100644 index 000000000..0b9ed3909 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-filter-view.md @@ -0,0 +1,126 @@ +# Lark Sheet Filter View + +## 概念回顾 + +筛选视图是 sheet 内的多份独立筛选配置,每个视图持有自己的 `range` 和 `rules`,由独立 `view_id`(10 位随机字符串)标识。一个 sheet 可有多个视图,视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者,也不与该 sheet 上可能并存的筛选器(filter)互相影响。 + +`+filter-view-{create|update|delete}` 负责视图本身的 CRUD(create / update / delete);视图的"进入 / 退出"(激活态)是本地状态,不在工具语义内。 + +## 使用场景 + +读写筛选视图对象。本 reference 覆盖 4 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有筛选视图 | `+filter-view-list` | 获取 sheet 上所有视图(视图名、范围、规则) | +| 创建 / 更新 / 删除筛选视图 | `+filter-view-{create|update|delete}` | create / update / delete 三个独立 shortcut | + +典型工作流:先读取现有视图了解配置 → 执行创建 / 更新 / 删除 → **必须再次读取验证结果**。 + +**常见配置错误(必须注意)**: +- **视图范围必须覆盖表头行**:视图的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行 +- **更新前先读取**:用户说"调整这个视图"时,先用 `+filter-view-list` 拉到目标视图当前 rules,**只改差异列**再回写 +- **多次 create 不能复用 view_id**:复用应走 `update`,重复 `create` 会产生新视图 +- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+filter-view-list` | read | 对象 | +| `+filter-view-create` | write | 对象 | +| `+filter-view-update` | write | 对象 | +| `+filter-view-delete` | high-risk-write | 对象 | + +## Flags + +### `+filter-view-list` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--view-id` | string | optional | 按筛选视图 reference_id 过滤(命中即只返回单个视图) | + +### `+filter-view-create` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag | +| `--range` | string | required | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;create 必填,必须覆盖表头行 | +| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | + +### `+filter-view-update` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--view-id` | string | required | 目标筛选视图 reference_id | +| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?` 和 `filtered_columns?`;update 是整组覆盖式(先 `+filter-view-list` 回读再 patch;传空 `rules: []` 清空)。`range` 和 `view_name` 是独立 flag | +| `--range` | string | optional | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;update 时省略表示保留当前 range | +| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 | + +### `+filter-view-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--view-id` | string | required | 目标筛选视图 reference_id | + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+filter-view-create` `--properties` / `+filter-view-update` `--properties` + +_create / update 的视图属性_ + +**顶层字段**: +- `view_name` (string?) — 可选 — ⚠️ 已拎为独立 flag `--view-name`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `range` (string?) — 视图作用的单元格范围(A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `rules` (array?) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array, filtered_rows?: array } +- `filtered_columns` (array?) — 可选 + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`view_id` 是 10 位随机字符串,每个 sheet 可有多个视图。 + +### `+filter-view-list` + +```bash +# 列出某个 sheet 的全部筛选视图 +lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" + +# 按 view_id 精确定位 +lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" --view-id vAbcde1234 +``` + +### `+filter-view-create` + +`--range`(必填)/ `--view-name`(可选)是独立 flag;`rules` 走 `--properties`: + +```bash +lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \ + --view-name "活跃用户" --range "A1:F1000" \ + --properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}' +``` + +> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。 + +### `+filter-view-update` + +> ⚠️ update 是整组覆盖(PUT 语义):`--properties` **必传**,未在请求里出现的 rules / filtered_columns 会被清空。如要保留已有 rules,先 `+filter-view-list` 读回再合并写回。`--range` 变更会丢弃已有筛选规则属预期行为(rules 跟当前 range 绑定)。重复 `+filter-view-create` 不会复用 view_id,会产生新视图。 + +### `+filter-view-delete` + +> ⚠️ 删除**已存在**的视图不可逆;目标 view_id **不存在**时按幂等成功返回(不报错)。先 `--dry-run` 看 view_id 确认。 + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在,`--properties` 必传(整组覆盖式);`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:输出"将要 POST/PATCH/DELETE 的 view 请求模板",零网络副作用;`--sheet-name` 在 dry-run 输出里生成为 `` 占位符。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-view-list --view-id ` 比对当前 range + rules。 diff --git a/skills/lark-sheets/references/lark-sheets-filter-views.md b/skills/lark-sheets/references/lark-sheets-filter-views.md deleted file mode 100644 index 0535799b2..000000000 --- a/skills/lark-sheets/references/lark-sheets-filter-views.md +++ /dev/null @@ -1,193 +0,0 @@ -# Sheets Filter Views - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总筛选视图和筛选条件: - -- `+create-filter-view` -- `+update-filter-view` -- `+list-filter-views` -- `+get-filter-view` -- `+delete-filter-view` -- `+create-filter-view-condition` -- `+update-filter-view-condition` -- `+list-filter-view-conditions` -- `+get-filter-view-condition` -- `+delete-filter-view-condition` - - -## `+create-filter-view` - -对应命令:`lark-cli sheets +create-filter-view` - -在工作表中创建筛选视图,每个工作表最多 150 个。 - -```bash -lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "!A1:H14" - -lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --range "!A1:H14" --filter-view-name "我的筛选" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--range` | 是 | 筛选范围 | -| `--filter-view-name` | 否 | 显示名称 | -| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID | - -输出:`filter_view` - - -## `+update-filter-view` - -对应命令:`lark-cli sheets +update-filter-view` - -```bash -lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" --range "!A1:J20" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--range` | 否 | 新范围 | -| `--filter-view-name` | 否 | 新显示名称 | - - -## `+list-filter-views` - -对应命令:`lark-cli sheets +list-filter-views` - -```bash -lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "" -``` - -输出:`items[]`(`filter_view_id`、`filter_view_name`、`range`) - - -## `+get-filter-view` - -对应命令:`lark-cli sheets +get-filter-view` - -```bash -lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" -``` - -输出:`filter_view` - - -## `+delete-filter-view` - -对应命令:`lark-cli sheets +delete-filter-view` - -```bash -lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | - - -## `+create-filter-view-condition` - -对应命令:`lark-cli sheets +create-filter-view-condition` - -为筛选视图的指定列创建筛选条件。 - -```bash -# 数值筛选:E 列 < 6 -lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" \ - --condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]' - -# 文本筛选:G 列以 a 开头 -lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" \ - --condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--filter-view-id` | 是 | 筛选视图 ID | -| `--condition-id` | 是 | 列字母,如 `E` | -| `--filter-type` | 是 | `hiddenValue` / `number` / `text` / `color` | -| `--compare-type` | 否 | 比较运算符 | -| `--expected` | 是 | 筛选值 JSON 数组 | - -输出:`condition` - - -## `+update-filter-view-condition` - -对应命令:`lark-cli sheets +update-filter-view-condition` - -```bash -lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" --condition-id "E" \ - --filter-type "number" --compare-type "between" --expected '["2","10"]' -``` - -参数与创建条件相同,但 `filter-type` / `compare-type` / `expected` 可按需部分更新。 - - -## `+list-filter-view-conditions` - -对应命令:`lark-cli sheets +list-filter-view-conditions` - -```bash -lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" -``` - -输出:`items[]` - - -## `+get-filter-view-condition` - -对应命令:`lark-cli sheets +get-filter-view-condition` - -```bash -lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" --condition-id "E" -``` - -输出:`condition` - - -## `+delete-filter-view-condition` - -对应命令:`lark-cli sheets +delete-filter-view-condition` - -```bash -lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --filter-view-id "" --condition-id "E" -``` - -## 参考 - -- [dropdown](lark-sheets-dropdown.md) — 需要下拉值配合筛选时 -- [cell-data](lark-sheets-cell-data.md#find) — 只查数据时用 `+find` diff --git a/skills/lark-sheets/references/lark-sheets-filter.md b/skills/lark-sheets/references/lark-sheets-filter.md new file mode 100644 index 000000000..356bdb643 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-filter.md @@ -0,0 +1,119 @@ +# Lark Sheet Filter + +## 真对象硬约束 + 数量校验 + +1. **真对象**:当用户要求"筛选 / 只看 / 仅保留 X"时,**必须**通过 `+filter-{create|update|delete}` 创建真实的筛选器对象。**禁止**用"删除不符合条件的行" / "新建子表只放符合条件的行" / 用 `+cells-set` 覆盖原表来代替——这些做法会让原数据丢失或不可恢复。 +2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count`。`expected_count` 来自先用本地脚本在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。 +3. **混合文本列禁止字面比较**:筛选 key 是公式文本(如 `1000+200=1200`)或带单位的混合文本时,先在辅助列里抽出纯数值再筛选;不能直接用文本比较。 + +## 使用场景 + +读写筛选器对象。本 reference 覆盖 4 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有筛选器 | `+filter-list` | 获取筛选器的范围、规则和条件配置 | +| 创建/更新/删除筛选器 | `+filter-{create|update|delete}` | 对筛选器执行写入操作 | + +典型工作流:先读取现有筛选器了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +**只读场景例外**:用户只是想知道哪些数据满足条件、并不要求修改表格展示时,可以走 `lark-sheets-read-data` 读后文本回答,不必创建筛选器。 + +**常见配置错误(必须注意)**: +- **筛选范围必须覆盖表头行**:筛选器的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行。缺少表头会导致筛选条件无法正确匹配列 +- **更新已有筛选器前先读取**:如果子表上已存在筛选器,直接创建会报错或覆盖原有配置。应先用 `+filter-list` 查看是否存在筛选器,存在时使用 update 而非 create +- **筛选条件的列索引要精确**:筛选条件中的列标识必须与实际数据列精确对应,不要凭猜测填写 +- **”调整筛选逻辑”要先读旧配置**:用户说”调整筛选”时,先读取现有筛选器的完整配置,理解当前规则后再修改,不要从零创建 +- **创建后必须验证**:调用 `+filter-list` 确认筛选器配置正确且生效 +- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理。 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+filter-list` | read | 对象 | +| `+filter-create` | write | 对象 | +| `+filter-update` | write | 对象 | +| `+filter-delete` | high-risk-write | 对象 | + +## Flags + +### `+filter-list` + +_公共四件套 · 系统:`--dry-run`_ + +_仅含公共 / 系统 flag。_ + +### `+filter-create` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 `--properties` 中的 range 字段 | +| `--properties` | string + File + Stdin(复合 JSON) | optional | 筛选规则 JSON:`rules`(列级筛选规则数组)+ `filtered_columns?`(激活列索引提示)。`--properties` 整体可选——传它时 `rules` 不可为空;不传则只在 `--range` 上建立空筛选器(无列条件)。`range` 是独立 flag(不要再放此 JSON 里) | + +### `+filter-update` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag | +| `--range` | string | required | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段 | + +### `+filter-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +_仅含公共 / 系统 flag。_ + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+filter-create` `--properties` / `+filter-update` `--properties` + +_创建/更新的筛选器属性_ + +**顶层字段**: +- `range` (string) — 筛选对象作用的单元格范围(A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `rules` (array) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array, filtered_rows?: array } +- `filtered_columns` (array?) — 可选 + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`filter_id` 等同于 `sheet_id`(每个工作表至多一个筛选器)。 + +### `+filter-list` + +```bash +# 查看当前 sheet 的筛选器配置(filter_id 等于 sheet_id) +lark-cli sheets +filter-list --url "..." --sheet-id "$SID" +``` + +### `+filter-create` + +`--range` 是独立 flag(含表头行);`rules` 走 `--properties`: + +```bash +lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \ + --range "A1:F1000" \ + --properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}' +``` + +### `+filter-update` + +> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。 + +### `+filter-delete` + +```bash +lark-cli sheets +filter-delete --url "..." --sheet-id "$SID" --yes +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+filter-create` 校验 `--range` 至少 2 行(表头 + 至少 1 行数据);`+filter-update` 必须先 `+filter-list` 确认目标存在;`+filter-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:输出"将要 POST/PATCH/DELETE 的 filter 请求模板"。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-list` 查看当前筛选条件 + 已过滤行数。 diff --git a/skills/lark-sheets/references/lark-sheets-float-image.md b/skills/lark-sheets/references/lark-sheets-float-image.md new file mode 100644 index 000000000..26a2ccd58 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-float-image.md @@ -0,0 +1,158 @@ +# Lark Sheet Float Image + +> **单元格图片 vs 浮动图片**:飞书表格有两种图片类型,请根据需求选择正确的工具: +> - **单元格图片**:图片嵌入在单元格内部,随单元格移动,属于单元格内容的一部分。→ 使用 `+cells-set`,在 `rich_text` 中设置 `type: "embed-image"`(见 lark-sheets-write-cells)。 +> - **浮动图片**(本 Skill):图片悬浮在单元格上方,可自由指定位置、大小和层级,不属于任何单元格的内容。→ 使用本 Skill 的 `+float-image-{create|update|delete}`。 + +## 真对象硬约束 + +当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。 + +## 使用场景 + +读写**浮动图片**对象(悬浮在单元格上方的图片,不属于单元格内容)。本 reference 覆盖 4 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有浮动图片 | `+float-image-list` | 获取浮动图片的位置、大小和层级配置 | +| 创建/更新/删除浮动图片 | `+float-image-{create|update|delete}` | 对浮动图片执行写入操作 | + +典型工作流:先读取现有浮动图片了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +**常见配置错误(必须注意)**: +- **单元格图片 vs 浮动图片选择错误**:如果用户希望图片嵌入单元格内部(随单元格移动),应使用 `+cells-set` 的 `rich_text` + `embed-image`,而非本 Skill +- **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据 +- **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确 + +图片来源有三种方式,`+float-image-create` 上三者 **XOR、必给其一**(`--image` / `--image-token` / `--image-uri`): + +- **`--image <本地路径>`(首选,最省事)**:直接给本地图片文件路径(PNG/JPEG/GIF/BMP/HEIC 等)。CLI 会自动把它以 `parent_type=sheet_image` 上传,拿到 file_token 后创建浮动图,**不用你手动上传 / 取 token**。路径规则同其它本地文件 flag:必须是当前工作目录内的相对路径(绝对路径会被 Validate 拒,`--dry-run` 也会拦)。 +- `--image-token`:复用**已存在**的图片 file_token。常见来源:① `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"复用同一张图);② `+cells-set-image` 成功返回里的 `file_token`(它也是 `sheet_image` 上传句柄)。适合"同一张图复用到多处",省去重复上传。 +- `--image-uri`:图片 reference_id(image URI),由系统自动转 file_token。 + +> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`manage_float_image` 工具强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+float-image-list` | read | 对象 | +| `+float-image-create` | write | 对象 | +| `+float-image-update` | write | 对象 | +| `+float-image-delete` | high-risk-write | 对象 | + +## Flags + +### `+float-image-list` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--float-image-id` | string | optional | 按 id 过滤;省略时列工作表全部 | + +### `+float-image-create` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) | +| `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | +| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);图片上传链路返回的 reference_id | +| `--position-row` | int | required | 图片左上角所在行(0-based) | +| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | int | required | 图片宽度(像素) | +| `--size-height` | int | required | 图片高度(像素) | +| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) | +| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) | +| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 | +| `--image` | string | xor | 本地图片路径(PNG/JPEG 等);CLI 自动上传为 sheet_image 并用返回的 file_token,省去手动拿 token(与 --image-token / --image-uri 三选一) | + +### `+float-image-update` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--float-image-id` | string | required | 目标图片 id | +| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) | +| `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` | +| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);图片上传链路返回的 reference_id | +| `--position-row` | int | required | 图片左上角所在行(0-based) | +| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) | +| `--size-width` | int | required | 图片宽度(像素) | +| `--size-height` | int | required | 图片高度(像素) | +| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) | +| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) | +| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 | + +### `+float-image-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--float-image-id` | string | required | 目标图片 id | + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。浮动图片是 sheet 级对象——和单元格内嵌图片不同(后者走 `+cells-set`)。 + +### `+float-image-list` + +```bash +lark-cli sheets +float-image-list --url "..." --sheet-id "$SID" +``` + +### `+float-image-create` + +所有字段拍平为独立 flag:图片来源 `--image` / `--image-token` / `--image-uri`(三选一 XOR)/ `--image-name` / `--position-{row,col}` / `--size-{width,height}` / `--offset-{row,col}` / `--z-index`。 + +```bash +# 首选:直接给本地图片路径,CLI 自动上传(无需手动拿 token) +# 注意:--image-name 是 required(即使路径 basename 已经是 logo.png 也要显式传) +lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ + --image ./logo.png --image-name "logo.png" \ + --position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1 + +# 用已有 file_token(从 +float-image-list 的 image_token 或 +cells-set-image 返回的 file_token) +lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ + --image-name "logo.png" --image-token "$TOKEN" \ + --position-row 0 --position-col A --size-width 200 --size-height 150 + +# 用 reference_id(图片上传链路返回的 image reference_id;与 --image-token 二选一) +lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \ + --image-name "logo.png" --image-uri "$IMAGE_URI" \ + --position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1 +``` + +### `+float-image-update` + +> **update ≈ create,只有图片源可省**:`manage_float_image` 工具的 update 要求和 create 相同的核心字段——`--image-name`、`--position-{row,col}`、`--size-{width,height}` **全部必填**;唯一区别是**图片源(`--image-token` / `--image-uri`)可以全省**,省略即保留原图。这**不是**"只发改动字段"的 patch:缺任一核心字段会被工具拒绝(`+float-image-list` 不回传 `image_name`,CLI 无法替你回填)。 +> +> 推荐流程:先 `+float-image-list --float-image-id ` 回读当前 position / size,再带上 `--image-name` 和完整的 position / size 调一次 `+float-image-update`。 + +```bash +# 调整位置 + 尺寸,保留原图(不传图片源) +lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ + --float-image-id "$IMG_ID" --image-name "logo.png" \ + --position-row 5 --position-col C --size-width 300 --size-height 200 + +# 换图:额外带 --image-token,核心字段同样要给全 +lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \ + --float-image-id "$IMG_ID" --image-name "new-logo.png" --image-token "$NEW_TOKEN" \ + --position-row 5 --position-col C --size-width 300 --size-height 200 +``` + +### `+float-image-delete` + +```bash +lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image-id "$IMG_ID" --yes +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个**,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,并和 create 一样必填 `--image-name` / `--position-{row,col}` / `--size-{width,height}`(缺任一核心字段本地直接报错,不会静默发 0);图片源 `--image-token` / `--image-uri` 可省(省略保留原图),给则二选一;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板";传 `--image` 时会多打印一步本地图片上传(`POST /open-apis/drive/v1/medias/upload_all`,`parent_type=sheet_image`)。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+float-image-list --float-image-id ` 比对新位置 / 尺寸。 diff --git a/skills/lark-sheets/references/lark-sheets-float-images.md b/skills/lark-sheets/references/lark-sheets-float-images.md deleted file mode 100644 index 7c5f3f1b4..000000000 --- a/skills/lark-sheets/references/lark-sheets-float-images.md +++ /dev/null @@ -1,125 +0,0 @@ -# Sheets Float Images - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总浮动图片相关能力: - -- `+media-upload` -- `+create-float-image` -- `+update-float-image` -- `+get-float-image` -- `+list-float-images` -- `+delete-float-image` - - -## `+media-upload` - -对应命令:`lark-cli sheets +media-upload` - -把本地图片上传到指定电子表格的素材空间,返回 `file_token`,供 `+create-float-image` 使用。 - -```bash -lark-cli sheets +media-upload --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --file ./image.png -``` - -说明: - -- 内部调用 `drive/v1/medias/upload_all` -- `>20MB` 自动分片上传 -- `--file` 只能是当前工作目录下的相对路径 - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--file` | 是 | 本地图片路径,必须是相对路径 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`file_token`、`file_name`、`size`、`spreadsheet_token` - - -## `+create-float-image` - -对应命令:`lark-cli sheets +create-float-image` - -```bash -lark-cli sheets +create-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --float-image-token "boxcnXXXX" \ - --range "!A1:A1" --width 200 --height 150 -``` - -关键规则: - -- `--float-image-token` 必须来自 `+media-upload` -- `--range` 必须锚定单个单元格 -- `width` / `height` 必须 `>=20` -- `offset-x` / `offset-y` 必须 `>=0` - -输出:`float_image` - - -## `+update-float-image` - -对应命令:`lark-cli sheets +update-float-image` - -```bash -lark-cli sheets +update-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --float-image-id "fi12345678" \ - --width 400 --height 300 --offset-y 20 -``` - -至少需要传一个更新字段:`--range` / `--width` / `--height` / `--offset-x` / `--offset-y` - -输出:更新后的 `float_image` - - -## `+get-float-image` - -对应命令:`lark-cli sheets +get-float-image` - -```bash -lark-cli sheets +get-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --float-image-id "fi12345678" -``` - -输出:`float_image` - - -## `+list-float-images` - -对应命令:`lark-cli sheets +list-float-images` - -```bash -lark-cli sheets +list-float-images --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" -``` - -输出:`items[]` - - -## `+delete-float-image` - -对应命令:`lark-cli sheets +delete-float-image` - -```bash -lark-cli sheets +delete-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --float-image-id "fi12345678" -``` - -输出:`code`、`msg` - -## 读取图片内容 - -上述读接口只返回元数据,不返回图片字节。要读取图片内容,用 `float_image_token` 调: - -```bash -lark-cli docs +media-preview --token "" --output ./image.png -``` - -## 参考 - -- [cell-images](lark-sheets-cell-images.md) — 写入到单元格的图片 -- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` diff --git a/skills/lark-sheets/references/lark-sheets-formula-translation.md b/skills/lark-sheets/references/lark-sheets-formula-translation.md new file mode 100644 index 000000000..b2800ea2c --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-formula-translation.md @@ -0,0 +1,267 @@ +# 飞书表格公式生成规则 + +> **本文定位**:飞书公式正确性的**唯一权威**——书写任何飞书公式、或把 Excel 公式迁移到飞书前,先读本文。涵盖公式书写约定(绝对引用、范围语法)、投影 vs spill、ARRAYFORMULA / 数组语义、高风险引用函数、日期差、不支持函数清单。 +> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**(`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells` 与 `lark-sheets-core-operations`。本文不含 shortcut,铁律见 `lark-sheets-core-operations`。 + +**核心原则:飞书不像 Excel 365 那样默认 spill(溢出展开)。飞书普通公式遇到区域时默认"投影"(只取当前行/列对应的单个值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。** + +## 公式书写约定(写任何公式都先满足) + +- **绝对引用 `$`**:向下 / 向右填充前判断哪些引用要锁定——用户指定的固定 cell(`$C$3`)、要固定的数据范围(`$A$2:$B$5`)、锁列不锁行(`$A2`)、锁行不锁列(`B$1`)。填充前检查是否需固定汇率 / 税率 / 查找表 / 权重表,以及同列 / 同行公式结构是否一致。 +- **公式字符串用飞书范围语法**:写 `H:H`、`A2:B5`,**禁止** `H2:H` / `2:2`。这与 CLI 工具参数(如 `--range`)的 A1 表示法(`A1:D3`、`1:1`)写法不同,两者混淆会导致调用失败或公式报错。 + +## 翻译后必做:代码复现校验 + +公式语法翻译完之后,**必须**用本地脚本在源数据上独立复现一份"等价计算结果"再写入。流程: + +1. **挑 3-5 个代表性输入行**(首行 / 中段 / 末行 / 含空值 / 含异常格式各一) +2. **用 Python 复现 Excel 原公式的语义**(不是飞书译文的语义,而是用户原本想要的结果) +3. **写入飞书译文公式后回读这几行的实际值** +4. **三方对照**:`Excel 原公式语义 == Python 复现 == 飞书译文回读值`,全部一致才交付;不一致先排查(数组语义?日期差?范围引用?) + +**理由**:Excel→飞书的语法翻译很容易在 spill / 数组 / 日期差 / 范围引用上出现等价性偏差,仅靠语法转换通过不足以保证业务结果正确。 + +## 决策流程 + +1. 最终结果是**标量**(单值)→ 通常不需要 `ARRAYFORMULA` +2. 最终结果是**一维或二维数组**: + - 公式中**包含**飞书原生数组函数(如 FILTER、XLOOKUP、MAP 等)→ 无需加 `ARRAYFORMULA`,数组语义会自动传播到整个公式,包括原生数组函数外层接的标量运算(如 `+1`、`*100`) + - 公式中**不包含**任何原生数组函数,但在对区域做标量计算 → 加 `ARRAYFORMULA(<整个表达式>)` +3. Excel 依赖 `ROW(range)` 逐项驱动 `SUBTOTAL/INDIRECT/OFFSET` → 改用 `MAP(ARRAYFORMULA(ROW(...)), LAMBDA(r, ...))` +4. 内层 `INDEX/INDIRECT/OFFSET` 返回范围,外层 `SUMIF/COUNTIF/SUMIFS` 还要继续吃这些范围 → 改用 `MAP(..., LAMBDA(...))` 或 `REDUCE(..., LAMBDA(...))` +5. 公式意图是"对多个区域分别计算再汇总"(例如用 INDIRECT/OFFSET 对每行生成一个范围,再对所有范围聚合)→ 飞书不能直接返回"区域的列表",必须明确降维:用 `VSTACK` 垂直合并、`HSTACK` 水平合并、`TOCOL/TOROW` 展平,或 `REDUCE` 归约成标量 +6. 算日期差 → 不要写 `DAY(end-start)`,用 `DAYS`、`DATEDIF` 或直接 `end-start` + +## 飞书的投影行为(不是默认 spill) + +飞书普通公式对引用区域默认"投影"而不是"spill": + +- 单列区域 → 按当前公式所在行取值 +- 单行区域 → 按当前公式所在列取值 +- 二维区域 → 只有当前公式位置能映射到该区域时才取值,否则报错 +- 数组常量 `{...}` 或函数返回矩阵,在普通标量上下文里通常只取左上角 + +因此: +- `=A1:A2` 在飞书普通公式里不会 spill,只会投影到当前行 +- `=ABS(A2:B2)` 不会得到一整行,要写 `=ARRAYFORMULA(ABS(A2:B2))` +- `=TRUNC({1.1111,2.222},{1,2})` 要得到一整行,写 `=ARRAYFORMULA(TRUNC({1.1111,2.222},{1,2}))` + +## ARRAYFORMULA 使用规则 + +**前提:以下规则适用于公式中没有任何原生数组函数的情况。** 若公式中已有原生数组函数(如 FILTER、XLOOKUP、MAP 等),数组语义会自动传播到整个公式的求值过程,后续标量运算无需额外包 `ARRAYFORMULA`(见下一节)。 + +需要加 `ARRAYFORMULA` 的典型场景(公式中无原生数组函数时): + +- 算术运算:`+ - * / ^ %` +- 比较运算:`= <> > >= < <=` +- 标量数学函数:`ABS ROUND INT TRUNC MOD LOG LN SQRT SIN COS TAN ...` +- 文本函数:`LEN LEFT RIGHT MID UPPER LOWER TRIM TEXT VALUE ...` +- 日期函数:`YEAR MONTH DAY DATE TIME EDATE EOMONTH ...` +- 条件函数:`IF IFS IFERROR IFNA NOT ISNUMBER ISTEXT ISBLANK ...` +- 引用函数(高风险):`INDEX OFFSET COLUMN ROW MATCH` + +### 公式中有原生数组函数时,整个公式已进入数组模式 + +飞书的数组语义会在整个公式求值过程中累积传播:一旦某个原生数组函数运行,后续所有运算符和函数也会自动逐元素处理,无论它们出现在哪一层。 + +因此,以下写法**无需**额外包 `ARRAYFORMULA`: + +- `=FILTER(A2:A10,B2:B10="x")+1` ✓ +- `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100` ✓ +- `=ABS(FILTER(A2:A10,B2:B10>0))` ✓ +- `=MAP(A2:A10,LAMBDA(x,x*2))-1` ✓ + +对比:**没有原生数组函数**时必须加: + +- `=A2:A100*B2:B100` → `=ARRAYFORMULA(A2:A100*B2:B100)` ✓ +- `=IF(A2:A100>0,B2:B100,"")` → `=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))` ✓ + +## 飞书原生数组函数清单 + +以下函数按数组语义工作,通常**不需要额外包 `ARRAYFORMULA`**: + +`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP` + +> **注意:`SWITCH` 在飞书里被当作原生数组函数处理,这与 Excel 行为不同,不需要额外包 `ARRAYFORMULA`。** + +## IMPORTRANGE 跨工作簿引用限制 + +用 `IMPORTRANGE` 跨电子表格引用数据时有两条硬上限: + +- **嵌套最多 5 层**:被引用的表里若又用 `IMPORTRANGE` 继续引下一张表,整条引用链最多 5 层。 +- **每个工作表最多 100 个 `IMPORTRANGE` 引用**。 + +超限会让引用失效或报错。设计大量跨表汇总前先估算引用数,必要时先把数据落地到本表再计算。 + +## INDEX / OFFSET / COLUMN / ROW / MATCH 是高风险函数 + +这组函数容易让人误以为会自动把多值铺开,但在飞书里不能这样假设。 + +**高风险信号:** + +- 行号 / 列号 / 偏移量本身是数组 +- 结果本来应该是一行或一块二维区域 +- 外层还有算术、比较、`IF` 等继续处理它 + +更稳的写法: + +- `=ARRAYFORMULA(INDEX(...))` +- `=ARRAYFORMULA(OFFSET(...))` +- `=ARRAYFORMULA(COLUMN(...))` +- `=ARRAYFORMULA(ROW(...))` + +**例外:** 如果返回值只是立刻交给聚合函数消费,不需要额外包: + +- `=SUM(INDEX(A1:B2,0,1))` ✓ + +## Excel 隐式逐项求值,飞书里要显式写 MAP + +**典型特征:** + +- 外层是 `SUMPRODUCT`、`SUM` 等聚合 +- 内层用了 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等更偏"单值/单引用"的函数 +- Excel 会把中间结果逐项带进去算 +- 飞书里直接照抄,往往不能得到同样的逐项语义 + +同类本质也包括:`INDEX/INDIRECT/OFFSET` 先返回范围,外层再把这些范围交给 `SUMIF`、`COUNTIF`、`AVERAGEIF`、`SUMIFS` 等范围感知函数 —— 飞书里这些外层函数不会自动二次展开内层范围。 + +这时不要只会补 `ARRAYFORMULA`,要显式写"遍历"。最常用模板: + +```excel +=SUMPRODUCT( + MAP( + ARRAYFORMULA(ROW(目标范围)), + LAMBDA(r, 单行计算逻辑) + ) +) +``` + +同类场景也优先考虑 `MAP`: + +- `INDIRECT("A"&ROW(...))` +- `OFFSET(...,ROW(...)-ROW(...),...)` +- `SUBTOTAL(...)` +- `SUMIF(内层返回范围, ...)` +- `COUNTIF(内层返回范围, ...)` +- `SUMIFS(内层返回范围, ...)` +- 任何"希望对每一行 / 每一列各算一次"的模式 + +## 多层范围结果与三维以上结果 + +飞书公式结果只能是二维区域,不能是"数组的数组"。 + +### 多层范围不能自动二次展开 + +内层 `INDEX/INDIRECT/OFFSET` 返回的是二维范围,外层还想继续对这些范围做范围计算时,不要假设飞书会"再展开一层"。改用: + +- `MAP(..., LAMBDA(...))` 显式逐项算 +- `REDUCE(..., LAMBDA(...))` 显式累加/归约 + +### 真正的三维或更高维结果不能直接返回 + +典型触发场景:想把多个不同区域或不同条件的结果合并展示,例如: +- 对 A 列、B 列、C 列分别做 FILTER,想把三列结果并排展示 +- 对多个月份分别生成数据行,想把所有月份上下堆叠展示 + +飞书无法直接返回"多个区域的集合",必须先决定降维方式: + +- 上下堆叠:`=VSTACK(slice1, slice2, slice3)` +- 左右拼接:`=HSTACK(slice1, slice2, slice3)` +- 压成单列:`=TOCOL(...)` +- 压成单行:`=TOROW(...)` +- 只保留聚合值:`=REDUCE(slice1, {slice2,slice3}, LAMBDA(acc,x,acc+x))` + +不要替用户"偷定"第三维展示方式;如果用户没有明确说明怎么展示,至少先把结果改写成可见的二维形状。 + +## 不能机械照抄的 Excel 语法 + +### `@` 隐式交叉 + +Excel:`=@A1:A10`(强制单值,取当前行对应的值) + +飞书没有 `@` 运算符。飞书普通公式对引用区域默认就有投影语义,去掉 `@` 即可: + +- Excel: `=@A1:A10` +- 飞书: `=A1:A10` + +### `#` spill range + +Excel:`=A1#`(引用 A1 公式溢出的整片区域) + +飞书没有此语法,迁移方式: + +- spill 区域已知 → 改成明确范围 +- spill 区域未知 → 回到源公式重写,或用 `TAKE` / `DROP` / `ARRAY_CONSTRAIN` + +### 结构化引用 + +Excel:`=SUM(Table1[Amount])` + +飞书不支持结构化引用,改成显式 A1 区域:`=SUM(A2:A100)` + +### 老式 CSE 花括号 + +Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入) + +飞书改为:`=ARRAYFORMULA(A1:A10*B1:B10)` + +## 日期序列与日期差 + +飞书日期序列:`0 = 1899-12-30`,`1 = 1899-12-31`,没有 Excel 的 1900 年闰年兼容问题。 + +**高频错误写法(不要用):** + +- `=DAY(B2-A2)` ✗ — 差值会被当成日期序列号再拆字段 +- `=MONTH(B2-A2)` ✗ +- `=YEAR(B2-A2)` ✗ + +**正确写法:** + +- 天数差:`=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2` +- 月份差:`=DATEDIF(A2,B2,"M")` +- 年份差:`=DATEDIF(A2,B2,"Y")` +- 工作日差:`=NETWORKDAYS(A2,B2)` + +## 飞书不支持的函数 + +> 本段是"飞书不支持函数"的**唯一权威清单**(`lark-sheets-core-operations` 不再单列,统一指向这里)。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案: + +- `STOCKHISTORY` — 实时股票数据,飞书无等价函数,需手动导入数据 +- `WEBSERVICE` — 外部 HTTP 请求,飞书无等价函数 +- CUBE 系列(`CUBEVALUE`、`CUBEMEMBER`、`CUBESET`、`CUBERANK` 等)— OLAP cube 函数,飞书不支持 +- `GOOGLEFINANCE`、`GOOGLETRANSLATE` 等 Google 特有函数 — 无等价函数 +- `FORECAST.ETS` 系列(`FORECAST.ETS`、`FORECAST.ETS.STAT` 等)— 飞书不支持 +- `INFO`、`RTD` — 系统信息 / 实时数据函数,飞书不支持 +- `PIVOT` — 用 `+pivot-{create|update|delete}` 透视表对象替代 +- `AMORDEGRC`、`PHONETIC`、`DETECTLANGUAGE` — 飞书不支持 + +## 代表性改写示例 + +- 基础逐项计算 + - Excel: `=A2:A100*B2:B100` + - 飞书: `=ARRAYFORMULA(A2:A100*B2:B100)` +- 条件判断 + - Excel: `=IF(A2:A100>0,B2:B100,"")` + - 飞书: `=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))` +- 原生数组函数(无需改动) + - Excel: `=FILTER(A2:C100,B2:B100="East")` + - 飞书: `=FILTER(A2:C100,B2:B100="East")` +- 原生数组函数 + 标量运算(无需改动,数组语义自动传播) + - Excel: `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100` + - 飞书: `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100` +- 高风险引用函数 + - Excel: `=INDEX(A1:D2,{2,1},0)` + - 飞书: `=ARRAYFORMULA(INDEX(A1:D2,{2,1},0))` +- 日期差 + - 错误: `=DAY(B2-A2)` + - 推荐: `=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2` +- Excel 隐式逐项求值 + - Excel: `=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387))))` + - 飞书: `=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(row,SUBTOTAL(103,INDIRECT("E"&row)))))` +- 多层范围 / 二次展开 + - 错误思路: `=SUMIF(INDIRECT("E"&ROW($E$16:$E$387)),">0")` + - 飞书: `=MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUMIF(INDIRECT("E"&r),">0")))` +- 三维降二维(保留所有层) + - 飞书: `=VSTACK(slice1,slice2,slice3)` 或 `=HSTACK(slice1,slice2,slice3)` +- 三维降二维(只保留聚合值) + - 飞书: `=REDUCE(slice1,{slice2,slice3},LAMBDA(acc,x,acc+x))` diff --git a/skills/lark-sheets/references/lark-sheets-formula.md b/skills/lark-sheets/references/lark-sheets-formula.md deleted file mode 100644 index a00460924..000000000 --- a/skills/lark-sheets/references/lark-sheets-formula.md +++ /dev/null @@ -1,88 +0,0 @@ -# 飞书表格公式规则 - -> 生成或改写飞书电子表格公式时的参考规则。飞书不像 Excel 365 默认 spill,普通公式对区域默认“投影”(只取当前行/列对应的单值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。 - -## 写入方式 - -公式必须使用对象格式写入(参见 SKILL.md「单元格数据类型」): - -```bash ---values '[[{"type":"formula","text":"=SUM(A1:A10)"}]]' -``` - -## ARRAYFORMULA 判断流程 - -1. 结果是**标量**(单值)→ 不需要 -2. 结果是**数组**,且公式中**有**原生数组函数 → 不需要(数组语义自动传播) -3. 结果是**数组**,且公式中**无**原生数组函数,对区域做标量计算 → 加 `ARRAYFORMULA` - -```text -# 有原生数组函数,无需包裹 -=FILTER(A2:A10,B2:B10="x")+1 ✓ -=XLOOKUP(E2:E10,A2:A10,B2:B10)*100 ✓ -=MAP(A2:A10,LAMBDA(x,x*2))-1 ✓ - -# 无原生数组函数,必须包裹 -=ARRAYFORMULA(A2:A100*B2:B100) ✓ -=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))✓ -``` - -## 原生数组函数清单(无需 ARRAYFORMULA) - -`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP` - -## 高风险函数:INDEX / OFFSET / ROW / COLUMN / MATCH - -行号/列号/偏移量本身是数组时,必须显式包裹: - -```text -=ARRAYFORMULA(INDEX(...)) -=ARRAYFORMULA(ROW(...)) -``` - -例外:结果直接交给聚合函数消费时不需要:`=SUM(INDEX(A1:B2,0,1))` ✓ - -## 隐式逐项求值 → MAP/LAMBDA - -Excel 中 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等在 `SUMPRODUCT` 内会隐式逐行求值,飞书不会。用 `MAP` 显式遍历: - -```text -# Excel -=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387)))) - -# 飞书 -=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUBTOTAL(103,INDIRECT("E"&r))))) -``` - -同类场景:`SUMIF/COUNTIF/SUMIFS` 的范围参数来自 `INDIRECT/OFFSET` 时也需要 `MAP`。 - -## 多维结果降维 - -飞书公式结果只能是二维,不能返回“区域的列表”。合并多个区域时: - -| 需求 | 写法 | -|------|------| -| 上下堆叠 | `=VSTACK(a, b, c)` | -| 左右拼接 | `=HSTACK(a, b, c)` | -| 压成单列 | `=TOCOL(...)` | -| 压成单行 | `=TOROW(...)` | -| 归约为标量 | `=REDUCE(init, arr, LAMBDA(acc, x, ...))` | - -## 日期差 - -| 需求 | 正确写法 | 错误写法 | -|------|---------|---------| -| 天数差 | `=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2` | `=DAY(B2-A2)` | -| 月份差 | `=DATEDIF(A2,B2,"M")` | `=MONTH(B2-A2)` | -| 年份差 | `=DATEDIF(A2,B2,"Y")` | `=YEAR(B2-A2)` | -| 工作日差 | `=NETWORKDAYS(A2,B2)` | — | - -## 飞书不支持的 Excel 语法 - -| Excel 语法 | 飞书替代 | -|-----------|---------| -| `=@A1:A10`(隐式交叉) | `=A1:A10`(飞书默认投影,去掉 `@`) | -| `=A1#`(spill range) | 改成明确范围,或用 `TAKE`/`DROP`/`ARRAY_CONSTRAIN` | -| `=SUM(Table1[Amount])`(结构化引用) | `=SUM(A2:A100)`(改为 A1 区域) | -| `{=A1:A10*B1:B10}`(CSE 花括号) | `=ARRAYFORMULA(A1:A10*B1:B10)` | -| `STOCKHISTORY` / `WEBSERVICE` / `CUBE*` | 飞书无等价函数 | diff --git a/skills/lark-sheets/references/lark-sheets-pivot-table.md b/skills/lark-sheets/references/lark-sheets-pivot-table.md new file mode 100644 index 000000000..dad7a1bb5 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-pivot-table.md @@ -0,0 +1,166 @@ +# Lark Sheet Pivot Table + +## 真对象硬约束 + +当用户要求"透视表 / 分组汇总 / 交叉分析 / 按 X 统计 Y"时,**必须**通过 `+pivot-{create|update|delete}` 创建真实的透视表对象。**禁止**用 `SUMIFS` / `COUNTIFS` 等普通公式 + `+cells-set` 在原表中拼一张"看起来像透视表的汇总表"来代替。判断标准:交付后 `+pivot-list` 必须能返回该对象。 + +## 使用场景 + +读写透视表对象。本 reference 覆盖 4 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有透视表 | `+pivot-list` | 获取透视表的结构、数据源和配置 | +| 创建/更新/删除透视表 | `+pivot-{create|update|delete}` | 对透视表执行写入操作 | + +典型工作流:先读取现有透视表了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +## 行/值字段映射(创建前必做) + +创建透视表前先识别用户需求中的分组维度和聚合指标,**不要搞反**: + +- **rows(行字段)** = 分组维度,即"按什么分组"。例:部门、地区、医生、产品类别 +- **values(值字段)** = 聚合指标,即"统计什么数值"。例:销售额(聚合方式 `sum`)、订单数(聚合方式 `count`) +- **columns(列字段)** = 交叉维度(可选),即"再按什么横向展开"。例:月份、性别 + +| 用户说 | rows | values | columns | +|--------|------|--------|---------| +| "按部门统计人数" | 部门 | 姓名(`summarize_by: "count"`) | — | +| "按医生统计费用和结余" | 主管医生 | 费用(`"sum"`)、结余(`"sum"`) | — | +| "各部门男女人数" | 部门 | 姓名(`"count"`) | 性别 | + +**常见配置错误(必须注意)**: +- **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误 +- **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(`summarize_by: "sum"`)。不要把行列字段搞反 +- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`;"统计总额"→ `"sum"`;"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`。默认不要用 `count` 替代 `sum` +- **参数长度限制**:如果透视表配置 JSON 过长(数据源范围跨越大量行列),可能导致工具调用失败。此时应先确认数据范围的精确边界,避免传入过大的 range +- **创建后必须验证**:调用 `+pivot-list` 确认透视表结构正确 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+pivot-list` | read | 对象 | +| `+pivot-create` | write | 对象 | +| `+pivot-update` | write | 对象 | +| `+pivot-delete` | high-risk-write | 对象 | + +## Flags + +### `+pivot-list` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--pivot-table-id` | string | optional | 按 id 过滤 | + +### `+pivot-create` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.source) | +| `--target-position` | string | optional | 透视表落点子表内的起始 cell(A1 格式,如 `A1`),映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | +| `--target-sheet-id` | string | xor | 透视表落点目标子表的 reference_id(与 `--target-sheet-name` 互斥,优先于 --target-sheet-name;都不传时自动新建一张子表放置透视表——推荐)。与数据源 sheet 区分:数据源 sheet 写在 --source 的 A1 引用里(带 sheet 前缀,形如 `'Sheet1'!A1:D100`)。 | +| `--target-sheet-name` | string | xor | 透视表落点目标子表的名称(与 `--target-sheet-id` 互斥;都不传时自动新建一张子表放置透视表——推荐)。与数据源 sheet 区分:数据源 sheet 写在 --source 的 A1 引用里(带 sheet 前缀,形如 `'Sheet1'!A1:D100`)。 | +| `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `'SheetName'!StartCell:EndCell`,如 `'Sheet1'!A1:D100`) | +| `--range` | string | optional | 透视表左上角放置位置(A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 | + +### `+pivot-update` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--pivot-table-id` | string | required | 目标透视表 id | +| `--properties` | string + File + Stdin(复合 JSON) | required | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id ` 回读再 patch) | + +### `+pivot-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--pivot-table-id` | string | required | 目标透视表 id | + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+pivot-create` `--properties` / `+pivot-update` `--properties` + +_创建/更新的透视表属性_ + +**顶层字段**: +- `range` (string?) — 放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `source` (string?) — 源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100') — ⚠️ 已拎为独立 flag `--source`,请勿在此 JSON 内重复填写(同名以独立 flag 为准) +- `rows` (array?) — 纵向分组字段(行字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 } +- `columns` (array?) — 横向分组字段(列字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 } +- `filters` (array?) — 筛选区域字段(页字段) each: { field: string, display_name?: string, filter?: object, condition_filter?: object, group?: object } +- `values` (array?) — 要汇总的字段(至少需要 1 个) each: { field: string, display_name?: string, summarize_by?: enum, show_data_as?: enum, base_field?: string } +- `auto_fit_col` (boolean?) — 是否自动调整列宽以适应内容 +- `show_row_grand_total` (boolean?) — 是否显示行总计(默认 true) +- `show_col_grand_total` (boolean?) — 是否显示列总计(默认 true) +- `show_subtotals` (boolean?) — 是否显示分类小计(默认 true,应用于所有字段) +- `repeat_row_labels` (boolean?) — 是否显示重复项标签 +- `calculated_fields` (array?) — 计算字段列表 each: { name: string, formula: string, summarize_by?: enum } +- `collapse` (object?) — 行字段展开/折叠状态:字段名 -> 要折叠的项目列表 + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,其中 `--sheet-id` / `--sheet-name` 在 `+pivot-update` / `+pivot-delete` / `+pivot-list` 上是公共四件套语义(定位透视表所在 sheet,XOR 必传一个)。 + +**`+pivot-create` 例外**:placement 选择器用 `--target-sheet-id` / `--target-sheet-name`(XOR,两个都不传时后端自动新建子表存放产物,强烈推荐,绝不碰源数据)。数据源 sheet 写在 `--source` 的 `'SheetName'!Range` 里,不靠 sheet 选择器 flag。 + +### `+pivot-list` + +```bash +lark-cli sheets +pivot-list --url "..." --sheet-id "$SID" +``` + +### `+pivot-create` + +> 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。 +> +> **先理清 `+pivot-create` 上 4 个位置类入参(语义不同,别混)**: +> - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `'Sheet1'!A1:D100`,sheet 名按 A1 标准单引号包裹)。源 sheet 的名字在 `--source` 字符串里,**不**通过单独 flag 传。 +> - `--target-sheet-id` / `--target-sheet-name`:**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。 +> - `--target-position`(可选,A1 表示法,默认 `A1`):落点 sheet 内的起始 cell,映射到顶层 `target_position`。 +> - `--range`(可选,A1 单值,仅 create 生效):跟 `--target-position` 表达同一意图但映射到 `properties.range`,**两者不要同时给**。 +> +> **落点 3 种策略(互斥,选其一)**: +> 1. **默认(强烈推荐)**:`--target-sheet-id` / `--target-sheet-name` / `--target-position` / `--range` **全都不传** → 服务端**自动新建子表**存放产物,绝不碰任何已有数据。 +> 2. **放进指定的已有子表**:传 `--target-sheet-id <落点子表 id>`(或 `--target-sheet-name`),可选 `--target-position <子表内起点 cell>`。⚠️ **若落点子表就是源数据所在的 sheet**,必须配 `--target-position` 或 `--range` 指向源数据范围**之外**的位置,否则产物默认从 A1 起会盖在源数据上。 +> 3. **`--range`**:跟策略 2 等价(同样需要 `--target-sheet-id` / `--target-sheet-name` 指定落点子表,不然落到自动新建子表),只是用 `properties.range` 那条 wire 路径表达位置。同样的覆盖风险,同样需要避开源数据范围。 +> +> 一般用策略 1(默认新建子表)即可,零覆盖风险,无需任何 `--target-*` / `--range` flag。 + +```bash +# 策略 1(强烈推荐):不传任何落点 flag → 后端自动新建子表,零覆盖风险 +lark-cli sheets +pivot-create --url "..." \ + --source "'Sheet1'!A1:D100" --properties @pivot.json + +# 策略 2:落进指定的已有目标子表(注意目标 sheet ≠ 源 sheet,否则要配 --target-position 避开源数据) +lark-cli sheets +pivot-create --url "..." \ + --source "'Sheet1'!A1:D100" --target-sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json +``` + +### `+pivot-update` + +> 不允许改 `--source` / `--range`(透视表创建后位置/数据源固定);只能用 `--properties` 改 rows / columns / values / filters 等。先 `+pivot-list --pivot-table-id ` 回读再 patch,避免漏字段。 + +### `+pivot-delete` + +```bash +lark-cli sheets +pivot-delete --url "..." --sheet-id "$SID" --pivot-table-id "$PID" --yes +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`:`--url` / `--spreadsheet-token` XOR 必填;`+pivot-{update,delete,list}` 的 `--sheet-id` / `--sheet-name` XOR 必填一个;`+pivot-create` 例外(用 `--target-sheet-id` / `--target-sheet-name` 表达落点,两个都可空时触发 backend auto-create 子表,两个都给则报 mutually exclusive);`+pivot-create` 的 `--source` 必填且必须含表头行;`--properties` 中 `rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+pivot-list --pivot-table-id ` 并用 `+csv-get` 抽样读透视产物核对输出尺寸 + 总计行位置。 + +> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`snapshot.data.refs` 必须排除这些行(见 `lark-sheets-chart` 的「⚠️ chart 数据源引用 pivot 时必须排除总计行」段)。 diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md new file mode 100644 index 000000000..5a74e2728 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -0,0 +1,263 @@ +# Lark Sheet Range Operations + +## 结构性操作影响面预检(清除 / 合并 / 排序 / 移动前必做) + +`+cells-clear`、`+cells-{merge|unmerge}`、`+range-{move|copy|fill|sort}`(移动 / 复制 / 排序 / 自动填充)都会让既有引用关系发生偏移或失效。**操作前必须**先确认以下两点;否则禁止执行: + +1. **打印当前合并单元格 + 公式引用 + 数据验证范围**:用 `+sheet-info --include merges` + `+cells-get` 抽样目标区域和它周边的公式 / 透视表 / 图表 / 条件格式 / 筛选器的数据源;评估操作后这些引用是否仍指向正确数据。 +2. **`+cells-clear` 不得侵入用户授权范围之外**:清除范围只能是用户明示要清的区域;不要顺手清除"看起来没用"的相邻单元格。 + +排序场景的存储类型识别 + 辅助列抽数值的细则见下方「sort 操作前必读」章节。 + +## 使用场景 + +写入。对指定区域执行结构性操作。本 reference 覆盖 9 个 shortcut,按 4 类用途组织: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 清除内容/格式 | `+cells-clear` | "清空"、"删除内容"、"去掉格式" | +| 合并/取消合并单元格 | `+cells-{merge|unmerge}` | "合并单元格"、"取消合并" | +| 调整行高/列宽 | `+rows-resize / +cols-resize` | "加宽列"、"调整行高"、"自适应列宽" | +| 移动/复制/填充/排序 | `+range-{move|copy|fill|sort}` | "移动数据"、"复制到"、"自动填充"、"按某列排序" | + +注意: + +- 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`;"这列 / 整列"时使用 `J:J`。不要截断为局部矩形 +- 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作 +- 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测 +- `--copy-to-range`(`+cells-set` 的参数)复制的是值/公式/样式,不含行高列宽。需要统一尺寸时另行调用 `+rows-resize / +cols-resize` + +## 写入后列宽自适应(防内容遮挡) + +写入文本 / 数值后**必须**主动检查列宽是否适配,否则会出现"内容被截断 / 长数字显示为科学计数法 / 文本溢出被相邻列遮挡"等用户感知问题: + +1. **写入后回读最长内容字符数**:用 `+csv-get` 读目标列的实际写入内容,统计最长单元格的字符数(`max(len(cell) for cell in col)`)。汉字按 2 字符宽度估算,半角字母数字按 1 字符。 +2. **判定阈值**:当前列宽(用 `+sheet-info --include row_heights,col_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。 +3. **修复二选一**: + - **扩列宽**:用 `+rows-resize / +cols-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值) + - **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`),并用 `+rows-resize / +cols-resize` 调高对应行的行高 +4. **新增列默认列宽规则**:新增列宽度 ≥ `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素,**禁止**用默认 11 直接交付。 + +**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`)/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。 + +**⚠️ 合并单元格安全操作规则**(`+cells-{merge|unmerge}` 必读): + +1. **先读后写**:操作前必须用 `+sheet-info --include merges` 或 `+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。 +2. **不要对已合并区域重复 merge**:对已合并的区域再次调用 merge 会报错或产生不可预期结果。 +3. **修改合并区域的正确顺序**:先 `unmerge` → 修改内容/样式 → 再 `merge`。 +4. **对合并区域设置样式**:只对完整 range 设置一次 `cell_styles`(写在左上角单元格),其余位置用 `{}` 占位。 +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`)。 + +**唯一例外**:`+cells-unmerge` 原生支持传一个大 range 一次性取消其中所有合并区域,应直接单次调用,**不要**拆进 `+batch-update`。 + +**⚠️ sort 操作前必读:确认目标列的数据类型** + +排序按单元格的**存储类型**比较:纯数字按数值排序;文本字符串按**字典序**(`"1000"` 排在 `"999"` 之前,与数值相反);日期按时间戳排序。 + +以下形态**看起来像数字但实际是字符串**,直接 sort 会得到错误结果: + +| 示例 | 说明 | +|------|------| +| `843688.69+20042.35=863731.04` | 表达式文本(无前导 `=` 不是公式,整串按字典序比较) | +| `¥1,234.56` / `$1,234` | 带货币符号 | +| `1.2万` / `3.5亿` / `100kg` | 带中文 / 英文单位 | +| 前后含空格或不可见字符的数字串 | 被当文本 | +| 同列混文本和数字 | 排序后分块 | + +**硬性流程**: + +1. sort 前先用 `+csv-get` 抽样目标列的前 3–5 行确认原始值形态,不要只看列名和用户问题就直接排。 +2. 若是纯数字或日期 → 直接 sort。 +3. 若是带符号 / 表达式 / 单位的文本 → **不要直接排**: + - 简单场景(货币、千分位、单位前缀):新增辅助列,用公式提取数值(如 `=VALUE(SUBSTITUTE(SUBSTITUTE(A2,"¥",""),",",""))`),按辅助列排序,排完可按需清除辅助列。 +- 复杂场景(多段表达式、中文单位、混合格式):分批 `+csv-get` 读到本地,按数值排序后用 `+csv-put` / `+cells-set` 分批回写。 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cells-clear` | high-risk-write | 单元格 | +| `+cells-merge` | write | 单元格 | +| `+cells-unmerge` | write | 单元格 | +| `+rows-resize` | write | 工作表 | +| `+cols-resize` | write | 工作表 | +| `+range-move` | write | 区域 | +| `+range-copy` | write | 区域 | +| `+range-fill` | write | 区域 | +| `+range-sort` | write | 区域 | + +## Flags + +### `+cells-clear` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 清除范围(A1 格式) | +| `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) | + +### `+cells-merge` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 待合并 / 取消合并的范围(A1 格式) | +| `--merge-type` | string | optional | 合并方向(仅 `+cells-merge`)(可选值:`all` / `rows` / `columns`)(默认 `all`) | + +### `+cells-unmerge` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 待合并 / 取消合并的范围(A1 格式) | + +### `+rows-resize` + +_公共四件套 · 系统:`--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` | + +### `+cols-resize` + +_公共四件套 · 系统:`--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` | + +### `+range-move` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--source-range` | string | required | 源 A1 范围 | +| `--target-sheet-id` | string | optional | 目标子表 id;省略时同源 sheet | +| `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | + +### `+range-copy` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--source-range` | string | required | 源 A1 范围 | +| `--target-sheet-id` | string | optional | 目标子表 id;省略时同源 sheet | +| `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) | +| `--paste-type` | string | optional | 粘贴内容(仅 `+range-copy`)(可选值:`values` / `formulas` / `formats` / `all`)(默认 `all`) | + +### `+range-fill` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--source-range` | string | required | 填充模板范围(系列起始 cells) | +| `--target-range` | string | required | 目标填充范围(A1 格式) | +| `--series-type` | string | optional | 填充序列类型(可选值:`auto` / `linear` / `growth` / `date` / `copy`)(默认 `auto`) | + +### `+range-sort` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 排序范围(A1 格式;含或不含表头由 `--has-header` 决定) | +| `--sort-keys` | string + File + Stdin(复合 JSON) | required | JSON 数组:`[{"column":"<列字母>","ascending":}, ...]` | +| `--has-header` | bool | optional | 第一行是表头不参与排序,默认 false | + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+range-sort` `--sort-keys` + +_排序条件列表(仅 sort 操作)_ + +**数组项**(类型 object): +- `column` (string) — 排序依据的列字母(如 "C"、"D"),必须在 range 范围内 +- `ascending` (boolean) — 是否升序排序 + +## Examples + +> ⚠️ 本 skill 派生的 shortcut 跨 3 个分组:`+rows-resize` / `+cols-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。skill 视角统一在这里讲解。 + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 + +### `+cells-clear` + +> **删不掉嵌入对象**:`+cells-clear`(任何 `--scope`,含 `all`)只清单元格的值 / 格式,**删不掉**压在范围内的透视表 / 图表等嵌入对象——后端会报 `can not find embedded block`。删透视表用 `+pivot-delete`、删图表用 `+chart-delete`(先用 `+pivot-list` / `+chart-list` 拿对象 id)。 + +> 需要一次清除**多个不连续 range**(如把内容搬走后批量去掉散落各处的边框/底色)时,改用 `lark-sheets-batch-update` 的 `+cells-batch-clear`,避免对 `+cells-clear` 逐个 range 调用。 + +```bash +# dry-run 先看 +lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --dry-run +# 执行 +lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --yes +``` + +### `+cells-merge` / `+cells-unmerge` + +```bash +# 合并 A1:C1(可选 --merge-type all/rows/columns) +lark-cli sheets +cells-merge --url "..." --sheet-id "$SID" --range "A1:C1" +# 取消合并:传大 range 一次性取消其中所有合并区域 +lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100" +``` + +### `+rows-resize` / `+cols-resize` + +行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。每条 `--type` 必填: + +```bash +# 把第 2-10 行设为固定 30 px +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --type pixel --size 30 + +# 把 A-C 列设为固定 120 px +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --type pixel --size 120 + +# 第 1 行行高自动适应内容(列宽不支持 auto) +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type auto + +# 重置 A-E 列为默认列宽 +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:E" --type standard +``` + +> 同时出现在 `lark-sheets-sheet-structure.md` —— 行高 / 列宽调整也算行列结构层动作。 + +### `+range-move` / `+range-copy` + +> `+range-move` 会**清空源区域**(move = copy + clear_source);`+range-copy` 不动源。 + +### `+range-fill` + +```bash +# 用 A1:A2 的序列规律向下填充到 A3:A100(target 区域不能与 source 重叠,否则后端报 source overlaps destination) +lark-cli sheets +range-fill --url "..." --sheet-id "$SID" --source-range "A1:A2" --target-range "A3:A100" --series-type auto +``` + +### `+range-sort` + +```bash +# 按 C 列降序排 A1:E100(首行为表头不参与) +lark-cli sheets +range-sort --url "..." --sheet-id "$SID" --range "A1:E100" --has-header --sort-keys '[{"column":"C","ascending":false}]' +``` + +### 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`(只行高支持自适应)。 +- `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <影响范围>` 抽样比对。 diff --git a/skills/lark-sheets/references/lark-sheets-read-data.md b/skills/lark-sheets/references/lark-sheets-read-data.md new file mode 100644 index 000000000..ed54e4830 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-read-data.md @@ -0,0 +1,171 @@ +# Lark Sheet Read Data + +## 列格式多样性预探(写公式 / 排序 / 筛选前必做) + +> 对应 `lark-sheets-core-operations` 的 **R3 计算复现**——本节是 R3 在 read_data 工具层的具体落地。 + +对参与后续**计算 / 排序 / 筛选 / 公式提取**的列,**必须**先 sample **至少 50 行**(小表则全量),识别该列所有值类型变体后再设计公式 / 条件。只看前 10 行不够,因为下列差异通常潜伏在表尾或中段: + +- **日期列同时出现多种格式**:`YYYYMM`、`YYYY-MM-DD`、`YYYY/M/D`、带时间戳、文本"未知" +- **数值列混入公式文本 / 单位 / 注释**:`1000+200=1200`、`100元`、`/(合同未明确)`、`#N/A` +- **空值与 0 / "0" 混杂** +- **大小写 / 全角半角差异**("办公费" vs "办公费 "、"Sales" vs "sales") + +预探后必须在公式 / 筛选条件里用 `IFERROR` / `IFS` / 提取数值的辅助列处理所有变体;不能为了通过 head(10) 的样本就直接落地。一旦设计的逻辑只覆盖 sample 中出现的格式,就属于违规。 + +## 使用场景 + +读取。从飞书表格中读取单元格数据。本 reference 覆盖 3 个 shortcut,按读取目的选择: + +| 读取目的 | 用这个 shortcut | 数据去向 | 说明 | +|---------|----------------|---------|------| +| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(加 `--rows-json` 改为结构化 rows `{row_number, values:{列字母→值}}`);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) | +| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 | +| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 | + +**选择原则**: +- 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文 +- 要结构化、按 `row_number` / 列字母定位的输出 → `+csv-get --rows-json`(默认 CSV 串更省 token,超大表批量仍用默认) +- 需要公式/样式/批注 → `+cells-get` +- 只想知道某区域下拉框有哪些选项 → `+dropdown-get` + +⚠️ 超大数据请走"`+csv-get` 按 `--range` 行窗口(如 `A1:Z500` / `A501:Z1000` …)分批读到本地文件 + 本地脚本处理 + `+csv-put` 分批回写"。 + +**`+csv-get` 返回值核心设计**: +- `annotated_csv` — **CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀(N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV(如喂给本地脚本做解析),去前缀即可:`line.replace(/^\[row=\d+\] /, '')`。 +- `col_indices` — **定位列字母唯一入口**。在表头中找到目标字段是第 j 个(0-based),用 `col_indices[j]` 取列字母。**禁止手数逗号**——列数超过 10 时极易 off-by-one(例如把 W 误判为 X)。 +- `row_indices` — 程序化引用的备用数组。LLM 推理请用 `annotated_csv` 的前缀,不要查这个数组里的 index(把行号当数值用容易心算出错)。 +- `current_region` — 从请求范围扩展到被空行空列包围的连续数据区域(等价于 Excel Ctrl+Shift+*),适合先读少量行探表头、同时获知整表实际范围。 + +注意: + +- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前还必须先看 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置 +- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true` + +**常见配置错误(必须注意)**: +- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写 +- **了解结构 ≠ 读取全量数据**:探表不用读全表,但必须同时探两个方向的表头: + - **横向(列头)**:先读前几行,且**列范围必须覆盖所有列**——用 `+workbook-info` 拿总列数,`range` 末列填到最后一列(例如总列数是 N,则 `range: "A1:[列N]10"`)。列范围截短会遗漏右侧字段、后续写入列定位错误。 + - **纵向(行标)**:若左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,典型交叉表/透视布局),**必须再读 `A:A` 或 `A:B` 把行标列读到底**,拿全部行标。只读前几行会看不全表尾的行,导致批量写入漏改——这是"只改前 N 行、其余未更新"的主要成因。扁平列表(每行独立记录、列是字段)可跳过这一步,但仍要靠 `current_region` 兜底。 + - 数据量大或会进入上下文上限时,分批读 + 本地处理 + 分批回写,不要一口气拉全表到上下文。 +- **`+cells-get` 滥用**:当只需要数据值时,使用 `+csv-get`(token 开销约为 `+cells-get` 的 1/5)。只有确实需要公式、样式或批注时才用 `+cells-get` +- **忽略分页标志**:读取返回 `has_more=true` 时,说明还有更多数据。如果任务需要完整数据,必须继续分页读取,不能只处理第一页就开始写入 +- **直接按 `+cells-get` 返回二维数组下标推导真实位置(高频错误)**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `--skip-hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位 +- **CSV 行号计数错误(高频致命错误)**:`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数 +- **手动数列确定列号(高频致命错误)**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段(0-based),再用 `col_indices[j]` 获取该列的实际列字母 +- **用数据列的值推导行号(高频致命错误,常被巧合掩盖)**:CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号(1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑 +- **按 `row_count` 盲读空行(高频低效)**:`+workbook-info` 的 `row_count` 是 sheet 的**网格物理行数**(常是 200 / 1000 等默认值),不是数据末行;按它把 `--range` 拉到 `S200`(实际数据可能只到 `S32`)会读回大片空行,浪费上下文又干扰判断。真实数据末行以 `+csv-get` 返回的 `current_region` 为准(它就是数据边界),再按下方「确定数据范围的正确流程」确认末行。 +- **current_region 当作纯数据范围(高频致命错误)**:`current_region` 返回的是从请求范围向四周扩展到被空行空列包围的**连续非空区域**,等价于 Excel 的 Ctrl+Shift+\*。它包含该区域内**所有非空行**——不仅包含数据行,还可能包含标题行、汇总行(如"总计")、签名行(如"编制人/审批人")、脚注等非数据内容。**严禁直接将 `current_region` 的末尾行作为数据范围的结束行**。正确做法见下方「确定数据范围的正确流程」 + +### 确定数据范围的正确流程(排序、筛选、批量写入等操作前必做) + +当后续操作需要精确的数据范围(如排序、筛选、删除、批量写入)时,仅靠 `current_region` 探测到的范围是不够的——必须同时确认数据的**起始行**和**结束行**。具体步骤: + +1. **确认起始行**:读取前 5~10 行,识别表头行位置,数据起始行 = 表头行 + 1 +2. **确认结束行**(关键步骤,不可跳过):读取 `current_region` 末尾附近的若干行(建议读取末尾 5~10 行),逐行检查内容,排除非数据行: + - **汇总行**:内容为"合计"、"总计"、"小计"、"总计:"等 + - **签名/审批行**:内容为"编制人"、"审核人"、"部门负责人"等 + - **空行或分隔行**:整行为空或仅有边框 + - **备注/脚注行**:注释性文字、说明文字等 +3. **最终数据范围** = 起始行 ~ 最后一条有效数据行(排除非数据行) + +**示例**:`current_region` 返回 `A1:N51`,读取 Row 48~51 发现: + +- Row 49: 序号=47, 姓名=xxx, 有正常数据 → ✅ 数据行 +- Row 50: "总计", 有合并单元格 → ❌ 汇总行 +- Row 51: "总经理:...", "编制人:..." → ❌ 签名行 +- **正确数据范围 = A3:N49**(而非 A3:N51) + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cells-get` | read | 单元格 | +| `+dropdown-get` | read | 对象 | +| `+csv-get` | read | 单元格 | + +## Flags + +### `+cells-get` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) | +| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) | +| `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | +| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | + +### `+dropdown-get` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | A1 范围,如 `A2:A100`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) | + +### `+csv-get` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) | +| `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) | +| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` | +| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` | +| `--rows-json` | bool | optional | 返回结构化 rows(`{row_number, values:{列字母→值}}`)而非 CSV 文本,默认 `false` | + +## Examples + +### `+csv-get` + +公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR,后两者 XOR)。 + +示例: + +```bash +# 简单读(sheet 定位必填:--sheet-name 或 --sheet-id 必给一个;range 的 Sheet1! 前缀不能替代它) +lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:F30" + +# 用 sheet-name 模糊定位(运行时框架会先解析到 sheet-id) +lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" --range "A1:F30" +``` + +输出契约(envelope.data): + +- `annotated_csv` — 含 `[row=N]` 前缀的 CSV 主入口 +- `col_indices` / `row_indices` — 列字母 / 行号映射数组 +- `current_region` — 自动扩展到非空连续区域的 A1 范围。它是**真实数据边界**,**优先于 `+workbook-info` 的 `row_count`**(`row_count` 是网格物理行数,常是 200 / 1000 等默认值、远大于实际数据;按它盲读会拉回大片空行) +- `has_more` — 是否截断;截断后续读用 `--range` 接着读 + +**加 `--rows-json`:返回结构化 rows(而非 CSV 字符串)** + +```bash +lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:G20" --rows-json +``` + +`--rows-json` 下的输出契约(替换 `annotated_csv` / `col_indices` / `row_indices`): + +- `rows` — 数组,每元素 `{row_number, values}`。`row_number` 是真实表格行号(整数,下游需要行号的操作直接取它);`values` 按**列字母** key(如 `values["D"]`,绝对列字母)。**所有逻辑行都在 `rows` 里**。引号内换行已解析进单元格值,无需自己按 RFC-4180 拆行。 +- `data_not_fully_read` — **仅当没读全时出现**:`{read_through_row, data_extends_through_row, unread_rows, reread_range}`。出现即表示真实数据超出本次读取范围;批量写入前必须按 `reread_range` 重读全区,否则漏行。 +- 其余字段(`current_region` / `actual_range` / `has_more`)同上。 + +### `+cells-get` + +示例: + +```bash +# 读 A1:F10 的公式 + 样式(sheet 定位必填) +lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" \ + --range "A1:F10" --include formula,style +``` + +> ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。 + +### Validate / DryRun / Execute 约束 + +- `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。 +- `DryRun` 输出请求模板:`--sheet-name` 在 dry-run 输出里生成为 `` 占位符,不实际解析。 +- `Execute` 阶段才进行 sheet-name → sheet-id 解析与 API 调用。 diff --git a/skills/lark-sheets/references/lark-sheets-row-column-management.md b/skills/lark-sheets/references/lark-sheets-row-column-management.md deleted file mode 100644 index 1c4d553a6..000000000 --- a/skills/lark-sheets/references/lark-sheets-row-column-management.md +++ /dev/null @@ -1,151 +0,0 @@ -# Sheets Row and Column Management - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总行列结构操作: - -- `+add-dimension` -- `+insert-dimension` -- `+update-dimension` -- `+move-dimension` -- `+delete-dimension` - - -## `+add-dimension` - -对应命令:`lark-cli sheets +add-dimension` - -在工作表末尾追加空行或空列,不影响已有数据。 - -```bash -lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --length 10 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--length` | 是 | 追加数量(1-5000) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`addCount`、`majorDimension` - - -## `+insert-dimension` - -对应命令:`lark-cli sheets +insert-dimension` - -在指定位置插入空行或空列,已有数据向下或向右移动。 - -```bash -lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --start-index 3 --end-index 7 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--start-index` | 是 | 起始位置(0-indexed) | -| `--end-index` | 是 | 结束位置(0-indexed,不含) | -| `--inherit-style` | 否 | `BEFORE` 或 `AFTER` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:成功时 `data` 为空对象 `{}` - - -## `+update-dimension` - -对应命令:`lark-cli sheets +update-dimension` - -更新指定范围行/列的显隐状态和行高/列宽。 - -```bash -lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --start-index 1 --end-index 3 \ - --visible=false -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--start-index` | 是 | 起始位置(**1-indexed**,含) | -| `--end-index` | 是 | 结束位置(**1-indexed**,含) | -| `--visible` | 否 | `--visible=true` 或 `--visible=false` | -| `--fixed-size` | 否 | 行高或列宽(像素) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:成功时 `data` 为空对象 `{}` - - -## `+move-dimension` - -对应命令:`lark-cli sheets +move-dimension` - -将指定范围的行/列移动到目标位置。 - -```bash -lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS \ - --start-index 0 --end-index 1 --destination-index 4 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--start-index` | 是 | 源起始位置(0-indexed) | -| `--end-index` | 是 | 源结束位置(0-indexed,含) | -| `--destination-index` | 是 | 目标位置(0-indexed) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:成功时 `data` 为空对象 `{}` - - -## `+delete-dimension` - -对应命令:`lark-cli sheets +delete-dimension` - -删除指定范围的行或列。 - -```bash -lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --dimension ROWS --start-index 3 --end-index 7 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 工作表 ID | -| `--dimension` | 是 | `ROWS` 或 `COLUMNS` | -| `--start-index` | 是 | 起始位置(**1-indexed**,含) | -| `--end-index` | 是 | 结束位置(**1-indexed**,含) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出:`delCount`、`majorDimension` - -## 参考 - -- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 查看当前工作表信息 -- [cell-style-and-merge](lark-sheets-cell-style-and-merge.md) — 调整样式或合并单元格 diff --git a/skills/lark-sheets/references/lark-sheets-search-replace.md b/skills/lark-sheets/references/lark-sheets-search-replace.md new file mode 100644 index 000000000..9a7e98947 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-search-replace.md @@ -0,0 +1,111 @@ +# Lark Sheet Search & Replace + +## 替换前 dry-run + 范围明确(替换前必做) + +`+cells-replace` 的副作用是不可逆的(除非另写代码回滚)。执行前必须: + +1. **明确替换范围**:必须显式说明"只替换 X 列 / X 区域,还是全表替换"。**禁止**默认全表替换——容易误改无关列。范围应由用户指令决定,模糊时主动询问。 +2. **dry-run 命中数量**:先用 `+cells-search` 在同一范围、同一关键词、同一匹配选项(大小写 / 精确 / 正则)下统计命中数量。把数量和**期望命中数**(用户明示的或基于业务理解推断的)对照——一致才进入 `+cells-replace`,不一致先排查(关键词太宽?范围太大?)。 +3. **替换后回读校验**:执行后再次 `+cells-search` 旧关键词,预期为 0;并对替换后的若干代表性单元格回读确认值符合预期。 + +## 使用场景 + +读写。在飞书表格中搜索和替换文本。本 reference 覆盖 2 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 搜索/定位文本 | `+cells-search` | 返回匹配的单元格位置,支持正则、精确匹配等 | +| 查找并替换文本 | `+cells-replace` | 批量替换文本;`--regex` 模式下 `--replacement` 可用 `$1`、`$2` 引用 `--find` 的捕获组 | + +**常见配置错误(必须注意)**: +- **不要把操作动词当搜索词**:用户说"汇总金额"是一个操作动作(求和),不是要搜索"汇总金额"这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search` +- **不要用搜索来了解表格结构**:要了解表头和数据结构时,应使用 `+csv-get` 读取前几行,而不是用 `+cells-search` 逐个猜测字段名 +- **注意正则特殊字符**:使用正则匹配时,`.`、`*`、`(`、`)` 等特殊字符需要转义 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cells-search` | read | 单元格 | +| `+cells-replace` | write | 单元格 | + +## Flags + +### `+cells-search` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--find` | string | required | 待查找文本(与 `--regex` 配合时按正则解释) | +| `--range` | string | optional | 查找范围(A1 格式);省略时整表 | +| `--match-case` | bool | optional | 大小写敏感 | +| `--match-entire-cell` | bool | optional | 完全匹配整个单元格 | +| `--regex` | bool | optional | 把 `--find` 按正则解释 | +| `--include-formulas` | bool | optional | 也在公式文本中搜索 | +| `--max-matches` | int | optional | 防爆,默认 5000(隐藏 flag:不在 `--help` 列出,但可正常传入) | +| `--offset` | int | optional | 跳过前 N 个匹配(分页用),默认 0 | + +### `+cells-replace` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--find` | string | required | 待替换文本 | +| `--replacement` | string | required | 替换为;传空字符串 `""` 等价于「删除内容」 | +| `--range` | string | optional | 替换范围(A1 格式);省略时整表 | +| `--match-case` | bool | optional | 大小写敏感 | +| `--match-entire-cell` | bool | optional | 完全匹配整个单元格 | +| `--regex` | bool | optional | 把 `--find` 按正则解释 | +| `--include-formulas` | bool | optional | 也在公式文本中替换 | + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则)。 + +### `+cells-search` + +示例: + +```bash +# 普通查找 +lark-cli sheets +cells-search --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --find "张三" + +# 正则 + 范围限定 +lark-cli sheets +cells-search --spreadsheet-token shtXXX --sheet-id "$SID" \ + --find "^[A-Z]{2}-\\d{4}$" --regex --range "A2:A1000" +``` + +输出契约(envelope.data): + +- `matches` — 命中 cell 列表,每条含 `address`(A1)+ `value` + `sheet_id` +- `total_matches` — 匹配总数 +- `has_more` / `next_offset` — 分页游标(命中数超过单页上限时用于继续读取) + +### `+cells-replace` + +示例: + +```bash +# 先 dry-run 预览 +lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --find "v1" --replacement "v2" --dry-run + +# 确认后执行 +lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --find "v1" --replacement "v2" + +# 正则捕获组:把 "2026-03" 重排成 "03/2026"($1/$2 引用 --find 的捕获组) +lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --regex --find "(\\d{4})-(\\d{2})" --replacement "$2/$1" --dry-run +``` + +> `+cells-replace` 虽然 Risk = write,但范围大或正则错可能改一堆。**强烈推荐工作流**:先 `+cells-search` 看匹配数,再 `+cells-replace --dry-run` 预览,最后真正执行。 + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`--find` 非空;正则模式下 `--find` 必须是合法正则。 +- `DryRun`:`+cells-search` 输出请求模板;`+cells-replace` 额外返回预估替换数(`would_replace_count`)。 +- `Execute`:写后不自动回读;如需确认,自行用 `+cells-search` 复查旧值是否已不再命中。 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-management.md b/skills/lark-sheets/references/lark-sheets-sheet-management.md deleted file mode 100644 index 089059c91..000000000 --- a/skills/lark-sheets/references/lark-sheets-sheet-management.md +++ /dev/null @@ -1,164 +0,0 @@ -# Sheets Sheet Management - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总工作表级操作: - -- `+create-sheet` -- `+copy-sheet` -- `+delete-sheet` -- `+update-sheet` - -其中 `+create-sheet` / `+copy-sheet` / `+delete-sheet` 底层封装官方“操作工作表(operate-sheets)”接口;`+update-sheet` 封装“更新工作表属性”接口。 - - -## `+create-sheet` - -对应命令:`lark-cli sheets +create-sheet` - -```bash -# 在表格末尾或服务端默认位置创建工作表 -lark-cli sheets +create-sheet --spreadsheet-token "shtxxxxxxxx" \ - --title "明细" - -# 指定插入位置(0-based) -lark-cli sheets +create-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --title "汇总" --index 0 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--title` | 否 | 工作表标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | -| `--index` | 否 | 工作表位置(从 0 开始) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheet_token` -- `sheet.sheet_id` -- `sheet.title` -- `sheet.index` - - -## `+copy-sheet` - -对应命令:`lark-cli sheets +copy-sheet` - -```bash -# 按默认位置复制 -lark-cli sheets +copy-sheet --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" - -# 指定副本名称和位置 -lark-cli sheets +copy-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --title "销售副本" --index 2 -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 源工作表 ID | -| `--title` | 否 | 新工作表标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | -| `--index` | 否 | 新工作表位置(从 0 开始) | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -说明: - -- 传 `--index` 时,CLI 会先复制,再追加一次位置更新,把副本移动到目标索引 - -输出: - -- `spreadsheet_token` -- `sheet.sheet_id` -- `sheet.title` -- `sheet.index` - - -## `+delete-sheet` - -对应命令:`lark-cli sheets +delete-sheet` - -> [!CAUTION] -> 这是**高风险删除操作**。CLI 会要求显式确认;可以先用 `--dry-run` 预览。 - -```bash -lark-cli sheets +delete-sheet --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 要删除的工作表 ID | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `deleted` -- `spreadsheet_token` -- `sheet_id` - - -## `+update-sheet` - -对应命令:`lark-cli sheets +update-sheet` - -用于更新工作表标题、位置、隐藏状态、冻结行列和保护设置。 - -```bash -# 改名 + 调整冻结 -lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --title "汇总表" --frozen-row-count 2 --frozen-col-count 1 - -# 隐藏工作表 -lark-cli sheets +update-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --sheet-id "" --hidden=true - -# 开启保护并授权额外编辑人 -lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \ - --sheet-id "" --lock LOCK --lock-info "仅财务维护" \ - --user-id-type open_id --user-ids '["ou_xxx","ou_yyy"]' -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 表格 token | -| `--sheet-id` | 是 | 要更新的工作表 ID | -| `--title` | 否 | 新标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` | -| `--index` | 否 | 新位置(从 0 开始) | -| `--hidden` | 否 | `--hidden=true` 隐藏,`--hidden=false` 取消隐藏 | -| `--frozen-row-count` | 否 | 冻结行数,`0` 表示取消冻结 | -| `--frozen-col-count` | 否 | 冻结列数,`0` 表示取消冻结 | -| `--lock` | 否 | 保护模式:`LOCK` / `UNLOCK` | -| `--lock-info` | 否 | 保护备注;要求 `--lock LOCK` | -| `--user-id-type` | 否 | `--user-ids` 的 ID 类型:`open_id` / `union_id` / `lark_id` / `user_id` | -| `--user-ids` | 否 | 额外可编辑用户 ID 的 JSON 数组;要求 `--lock LOCK` | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheet_token` -- `sheet.sheet_id` -- `sheet.title` -- `sheet.hidden` -- `sheet.grid_properties.frozen_row_count` -- `sheet.grid_properties.frozen_column_count` -- `sheet.protect` - -## 参考 - -- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id` -- [row-column-management](lark-sheets-row-column-management.md) — 需要改行列结构时用这组命令 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md new file mode 100644 index 000000000..16473e3b7 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -0,0 +1,212 @@ +# Lark Sheet Sheet Structure + +## 结构性操作影响面预检(插入 / 删除行列前必做) + +插入 / 删除行列、隐藏 / 取消隐藏、冻结、行列分组都会让原表的引用关系发生偏移。**操作前必须**先打印以下三类信息,并评估操作是否会让它们失效;否则禁止执行: + +1. **当前合并单元格范围**(来自 `+sheet-info` 的 `merged_cells`):插入行 / 列时,跨过插入位置的合并区域可能扩张或断裂;删除行 / 列时合并区域可能直接消失。 +2. **现有公式的引用范围**(用 `+cells-get` 抽样附近行 + 跨表引用 + 透视表 / 图表 / 条件格式 / 筛选器的数据源 range):插入 / 删除会导致 `=SUM(B4:B13)` 这种相对引用偏移;如果操作发生在引用范围内部,可能产生 `#REF!`。 +3. **数据验证(下拉列表)规则的应用范围**:列表来源是某个区域时,区域被部分删除会让规则失效。 + +不可逆的影响必须先在回复中告知用户,得到确认再执行。 + +## 使用场景 + +读写。管理子表结构与布局。本 reference 覆盖 9 个 shortcut(按用途分两类): + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看子表布局 | `+sheet-info` | 获取行高、列宽、隐藏行列、行列分组、合并单元格等信息 | +| 变更子表结构 | `+dim-{insert|delete|hide|unhide|freeze|group|ungroup|move}` | 插入/删除/隐藏/取消隐藏/冻结/分组/移动行列 | + +注意: + +- 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义 +- 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容 +- 插入用 `+dim-insert`:`--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**)+ `--count`(插入数量,>0)。新行/列样式继承用 `--inherit-style`(`before`/`after`/`none`) +- 例如"在第 20 行后新增 116 行":`--position 21 --count 116`("第 20 行后"即 1-based 行号 21) + +**区间表达统一为 A1 风格**:所有涉及"一段连续行/列"的 shortcut 都用同一套 A1 闭区间字符串语法,**不存在 inclusive / exclusive / 0-based / 1-based 跨命令差异**: + +| 命令 | 用什么 flag 表达区间 / 位置 | 例子 | +| --- | --- | --- | +| `+dim-insert` | `--position` + `--count` | `--position 3 --count 5`(在第 3 行前插 5 行)/ `--position C --count 2`(在 C 列前插 2 列) | +| `+dim-delete` / `+dim-hide` / `+dim-unhide` / `+dim-group` / `+dim-ungroup` / `+rows-resize` / `+cols-resize` | `--range` | `"3:7"`(第 3-7 行,闭区间)/ `"C:F"`(C-F 列,闭区间)/ `"5"` 或 `"C"`(单行/列) | +| `+dim-move` | `--source-range`(源区间)+ `--target`(目标位置) | `--source-range "3:7" --target 12`(把第 3-7 行移到第 12 行前)/ `--source-range "C:F" --target H` | + +行用 1-based 数字、列用字母——跟 Excel / 飞书 UI 看到的行号、列字母完全一致。 + +**常见配置错误(必须注意)**: +- **插入列直接用字母**:`+dim-insert` 的 `--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引 +- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置 +- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容 +- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before` +- **`+dim-move` 同维度约束**:`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用 +- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续 +- **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+sheet-info` | read | 工作表 | +| `+dim-insert` | write | 工作表 | +| `+dim-delete` | high-risk-write | 工作表 | +| `+dim-hide` | write | 工作表 | +| `+dim-unhide` | write | 工作表 | +| `+dim-freeze` | write | 工作表 | +| `+dim-group` | write | 工作表 | +| `+dim-ungroup` | write | 工作表 | +| `+dim-move` | write | 工作表 | + +## Flags + +### `+sheet-info` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--include` | string_slice | optional | 要返回的结构信息类别,逗号分隔多个(可选值:`merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`) | +| `--range` | string | optional | 限定只返回该 A1 范围的结构信息;省略时返回整表 | + +### `+dim-insert` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--inherit-style` | string | optional | 新行/列样式继承策略 enum:`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认)(可选值:`before` / `after` / `none`) | +| `--position` | string | required | 插入位置(在此行/列**之前**插入):行用 1-based 行号如 `3`;列用字母如 `C` | +| `--count` | int | required | 插入数量(>0) | + +### `+dim-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C` | + +### `+dim-hide` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 要隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` | + +### `+dim-unhide` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 要取消隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` | + +### `+dim-freeze` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | +| `--count` | int | required | 冻结前 N 行/列;传 0 解除冻结 | + +### `+dim-group` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--depth` | int | optional | 嵌套分组的层级(创建到第几层),默认 1 | +| `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`)(默认 `expand`) | +| `--range` | string | required | 要创建分组的行/列闭区间;行如 `3:7`,列如 `C:F` | + +### `+dim-ungroup` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--depth` | int | optional | 要取消的分组层级,默认 1(最外层) | +| `--range` | string | required | 要取消分组的行/列闭区间;行如 `3:7`,列如 `C:F` | + +### `+dim-move` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--source-range` | string | required | 要移动的源行/列闭区间;行如 `3:7`,列如 `C:F` | +| `--target` | string | required | 目标位置(移到此行/列**之前**):行用 1-based 行号如 `12`,列用字母如 `H`。必须与 `--source-range` 同维度(行/列) | + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 + +### `+sheet-info` + +输出契约:返回子表的行高 / 列宽 / 隐藏 / 合并 / 分组等布局元信息。 + +### `+dim-insert` + +```bash +# 在第 10 行前插 3 行,继承上方样式 +lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-id "$SID" --position 10 --count 3 --inherit-style before + +# 在 C 列前插 2 列 +lark-cli sheets +dim-insert --url "..." --sheet-id "$SID" --position C --count 2 +``` + +### `+dim-delete` + +```bash +# 删除第 5-7 行 +lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "5:7" --yes + +# 删除 D-F 列 +lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "D:F" --yes +``` + +### `+dim-hide` / `+dim-unhide` + +```bash +lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "5:7" +lark-cli sheets +dim-unhide --url "..." --sheet-id "$SID" --range "5:7" +lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "C:F" +``` + +### `+dim-move` + +```bash +# 把第 3-7 行移到第 12 行前 +lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "3:7" --target 12 + +# 把 C-F 列移到 H 列前 +lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --target H +``` + +### `+rows-resize` / `+cols-resize` + +> ⚠️ 这两条 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`(列宽不支持自动适应)。 + +### `+dim-freeze` + +```bash +# 冻结前 1 行(--count 传 0 解除冻结) +lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --count 1 +``` + +### `+dim-group` / `+dim-ungroup`(大纲) + +> 仅当用户明确说"行分组 / 列分组 / 大纲 / outline"时触发;按字段做数据分组用 `+pivot-create`。 + +### 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`。 +- `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。 diff --git a/skills/lark-sheets/references/lark-sheets-sparkline.md b/skills/lark-sheets/references/lark-sheets-sparkline.md new file mode 100644 index 000000000..090dbcda5 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-sparkline.md @@ -0,0 +1,149 @@ +# Lark Sheet Sparkline + +## 真对象硬约束 + +当用户要求"迷你图 / 趋势线 / 单元格内图表"时,**必须**通过 `+sparkline-{create|update|delete}` 创建真实的迷你图对象。**禁止**用文本字符(如 `▁▂▃▅▇`)拼接在单元格里、或用 `SPARKLINE()` 公式函数(已禁用)代替。判断标准:交付后 `+sparkline-list` 必须能返回该对象。 + +## 使用场景 + +读写迷你图对象。本 reference 覆盖 4 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看已有迷你图 | `+sparkline-list` | 获取迷你图的类型、数据源和样式配置 | +| 创建/更新/删除迷你图 | `+sparkline-{create|update|delete}` | 对迷你图执行写入操作 | + +典型工作流:先读取现有迷你图了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。 + +**常见配置错误(必须注意)**: +- **数据源范围要精确**:迷你图的数据源范围必须与实际数据行列精确对应,范围偏移会导致图形展示错误 +- **不要与 SPARKLINE() 公式混淆**:飞书表格的 `SPARKLINE()` 公式函数已被禁用,迷你图只能通过本 Skill 的对象方式创建 +- **创建后必须验证**:调用 `+sparkline-list` 确认迷你图配置正确 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+sparkline-list` | read | 对象 | +| `+sparkline-create` | write | 对象 | +| `+sparkline-update` | write | 对象 | +| `+sparkline-delete` | high-risk-write | 对象 | + +## Flags + +### `+sparkline-list` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--group-id` | string | optional | 按 group_id 过滤 | + +### `+sparkline-create` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:`{config(共享样式配置), sparklines(迷你图数组)}`;完整字段结构跑 `--print-schema` | + +### `+sparkline-update` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--group-id` | string | required | 目标组 id | +| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:`{config, sparklines}`;先 `+sparkline-list --group-id ` 回读再 patch;完整字段结构跑 `--print-schema` | + +### `+sparkline-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--group-id` | string | required | 目标组 id | + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+sparkline-create` `--properties` / `+sparkline-update` `--properties` + +_创建/更新/部分删除的迷你图属性_ + +**顶层字段**: +- `config` (object?) — 迷你图样式配置, 相同 groupId 的迷你图共享相同的样式 { theme_type?: enum, non_num_show_as?: enum, empty_show_as?: enum, contain_hidden_cells?: boolean, series_color?: string, …共 13 项 } +- `sparklines` (array?) — 迷你图项列表 each: { sparkline_id?: string, position?: object, source?: string, source_range?: object } + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。迷你图用 **两层 id** 管理——`group_id` 选组(一组同形态的迷你图共享类型 / 样式 / 数据源映射),`sparkline_id` 在组内选具体某一项。注意:不等同于已禁用的 `SPARKLINE()` 公式函数。 + +> **何时需要先 `+sparkline-list`:** +> - `+sparkline-update`:**总是**需要——拿到组内每一项的 `sparkline_id`,回填到 `properties.sparklines[i]`,server 用它做映射。 +> - `+sparkline-delete`:**不需要** `sparkline_id`——CLI 仅支持按 `--group-id` 整组删除(该 shortcut 没有 `--properties`)。 + +### `+sparkline-list` + +```bash +# 列出整张子表的所有迷你图组 +lark-cli sheets +sparkline-list --url "..." --sheet-id "$SID" + +# 钉到单组:返回该组每一项的 sparkline_id(update / partial-delete 必需) +lark-cli sheets +sparkline-list --url "..." --sheet-id "$SID" --group-id "grpA" +``` + +### `+sparkline-create` + +> `--properties` 顶层只有 `config`(同组共享样式,如 `line_width` / `points` / `extremum_max` / `extremum_min`)和 `sparklines`(迷你图项数组)两个字段。`sparklines[i]` 每项必须含 `position`(落点 cell,`row` + `col`)+ `source`(数据 A1 范围,与 `source_range` 二选一);create 时 `sparkline_id` 可省略,由系统生成。 + +```bash +lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --properties @sparkline.json +``` + +`sparkline.json` 示例(在 F 列嵌入两行折线迷你图,数据分别来自 A2:E2 和 A3:E3): + +```jsonc +{ + "config": { "line_width": 2 }, + "sparklines": [ + {"position": {"row": 1, "col": "F"}, "source": "'Sheet1'!A2:E2"}, + {"position": {"row": 2, "col": "F"}, "source": "'Sheet1'!A3:E3"} + ] +} +``` + +### `+sparkline-update` + +> 两步式:先 `+sparkline-list --group-id ` 拿当前组的 `sparkline_id` 列表,再构造 `properties.sparklines[]`——**每项必须带 `sparkline_id`**。只改样式可只传 `properties.config`(不带 `sparklines`,整组样式覆盖式更新)。 + +```bash +# 假设 +sparkline-list 已返回 group_id=grpA,组内 sparkline_id=sl_1 / sl_2 +lark-cli sheets +sparkline-update --url "..." --sheet-id "$SID" --group-id "grpA" --properties '{ + "sparklines": [ + {"sparkline_id":"sl_1","source":"'Sheet1'!A2:A20"}, + {"sparkline_id":"sl_2","source":"'Sheet1'!B2:B20"} + ] +}' +``` + +### `+sparkline-delete` + +> CLI 仅支持**整组删除**:传 `--group-id` 删掉该组全部迷你图。该 shortcut **没有** `--properties`,无法只删组内单项(需求上要"留一部分"时,改用 `+sparkline-update` 重写该组的 `sparklines` 列表,而不是 delete)。强制 `--yes` 或 `--dry-run`;先 `--dry-run` 确认要删的目标组。 + +```bash +# 删整组 +lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA" --yes +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`: + - XOR 公共四件套;`+sparkline-{update,delete}` 必须 `--group-id`。 + - **`+sparkline-update`**:当 `properties.sparklines` 非空时,每一项必须含 `sparkline_id`(CLI 预检,错误信息会指回 `+sparkline-list`,避免命中服务端的不可读拒绝);只传 `properties.config`(config-only update)合法、不触发 sparkline_id 检查。 + - **`+sparkline-delete`**:只接 `--group-id`(整组删除),**没有** `--properties`,无法删组内单项。 + - `--properties`(仅 `+sparkline-create` / `+sparkline-update`)顶层只接 `config`(同组共享样式)和 `sparklines`(迷你图项数组);`+sparkline-create` 要求每个 `sparklines[i]` 含 `position` 与 `source`(或 `source_range`,二选一)。 + - `+sparkline-delete` 强制 `--yes` 或 `--dry-run`。 +- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 sparkline group 请求模板"。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+sparkline-list --group-id ` 查看 `config` / `sparklines`。 diff --git a/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md b/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md deleted file mode 100644 index b6bfe591e..000000000 --- a/skills/lark-sheets/references/lark-sheets-spreadsheet-management.md +++ /dev/null @@ -1,140 +0,0 @@ -# Sheets Spreadsheet Management - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -这份 reference 汇总电子表格对象级操作: - -- `+create`:创建电子表格 -- `+info`:查看电子表格元信息和工作表列表 -- `+export`:导出电子表格 - - -## `+create` - -对应命令:`lark-cli sheets +create` - -特性: - -- 一步创建表格并返回 URL -- 可选 `--headers/--data` 在创建后自动写入第一个工作表的 A1 开始 -- `--as bot` 创建成功后,CLI 会尝试为当前 CLI 用户自动授予 `full_access` - -```bash -# 只创建表格 -lark-cli sheets +create --title "仓库管理营收报表" - -# 创建并写入表头 + 初始数据 -lark-cli sheets +create --title "仓库管理营收报表" \ - --headers '["仓库","统计月份","入库金额","出库金额","销售收入","毛利率"]' \ - --data '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]' - -# 创建到指定文件夹 -lark-cli sheets +create --title "测试表" --folder-token "fldbc_xxx" - -# 仅预览请求 -lark-cli sheets +create --title "测试表" --dry-run -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--title` | 是 | 表格标题 | -| `--folder-token` | 否 | 创建到指定文件夹 | -| `--headers` | 否 | 一维数组 JSON,作为表头写入 | -| `--data` | 否 | 二维数组 JSON,作为初始数据写入 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheet_token` -- `title` -- `url` -- `permission_grant`(仅 `--as bot` 时返回) - - -## `+info` - -对应命令:`lark-cli sheets +info` - -用于: - -- 从表格 URL / token 获取 `spreadsheet_token` -- 获取电子表格标题、URL、所有者等元信息 -- 列出工作表的 `sheet_id`、标题、行列数、冻结状态等信息 - -权限说明: - -- 该 shortcut 声明了 `sheets:spreadsheet.meta:read` 和 `sheets:spreadsheet:read`,本地 scope preflight 要求两者同时满足 -- `spreadsheet` 元信息来自 `spreadsheets/:token` 查询,工作表列表来自额外的 `spreadsheets/:token/sheets/query` 查询 - -```bash -# 传 URL(支持 wiki URL) -lark-cli sheets +info --url "https://example.larksuite.com/sheets/shtxxxxxxxx" - -# 传 spreadsheet_token -lark-cli sheets +info --spreadsheet-token "shtxxxxxxxx" - -# 仅预览请求 -lark-cli sheets +info --url "https://..." --dry-run -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一;支持 wiki URL) | -| `--spreadsheet-token` | 否 | 电子表格 token | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- `spreadsheet.spreadsheet.token` -- `spreadsheet.spreadsheet.url` -- `sheets.sheets[]` - - -## `+export` - -对应命令:`lark-cli sheets +export` - -特性: - -- 创建导出任务并轮询完成 -- 支持导出 `xlsx` 或 `csv` -- 提供 `--output-path` 时自动下载,否则只返回 `file_token` - -```bash -# 导出 xlsx 并保存到本地 -lark-cli sheets +export --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ - --file-extension xlsx --output-path "./report.xlsx" - -# 导出 csv(必须指定 sheet-id) -lark-cli sheets +export --spreadsheet-token "shtxxxxxxxx" \ - --file-extension csv --sheet-id "" --output-path "./report.csv" - -# 只返回导出文件 token -lark-cli sheets +export --spreadsheet-token "shtxxxxxxxx" --file-extension xlsx -``` - -参数: - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | -| `--spreadsheet-token` | 否 | 电子表格 token | -| `--file-extension` | 是 | `xlsx` 或 `csv` | -| `--sheet-id` | 否 | 导出 `csv` 时必填 | -| `--output-path` | 否 | 保存到本地的路径 | -| `--dry-run` | 否 | 仅打印请求,不执行 | - -输出: - -- 提供 `--output-path`:`saved_path`、`file_name`、`file_size` -- 不提供 `--output-path`:`file_token`、`file_name`、`file_size` - -## 参考 - -- [sheet-management](lark-sheets-sheet-management.md) — 管理工作表 -- [cell-data](lark-sheets-cell-data.md) — 读写单元格数据 -- [float-images](lark-sheets-float-images.md) — 上传和管理浮动图片 diff --git a/skills/lark-sheets/references/lark-sheets-visual-standards.md b/skills/lark-sheets/references/lark-sheets-visual-standards.md new file mode 100644 index 000000000..d1ffc57ac --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-visual-standards.md @@ -0,0 +1,204 @@ +# 飞书表格样式与配色规范 + +> **本文定位**:飞书表格"正确视觉输出"的取值标准与美化决策流——配色、表头、对齐、数值格式、斑马纹、列宽行高、图表展示,以及新增 / 继承 / 美化已有区域三类场景的做法。 +> **边界**:本文只讲"样式长什么样、怎么决策";**怎么调用工具写入样式**(`cell_styles` / `border_styles` 字段、合并、resize 等参数)见 `lark-sheets-write-cells` / `lark-sheets-range-operations` / `lark-sheets-batch-update`。**条件格式**(高亮 / 标红 / 数据条 / 色阶)见 `lark-sheets-conditional-format`。本文不含 shortcut,铁律见 `lark-sheets-core-operations`。 + +## 最高优先级原则 + +- **用户指令优先**:用户明确提出的格式要求(如"使用红色背景")具有最高权重,即使与通用审美冲突。 +- **继承原表风格**:编辑前先采样原文件视觉特征(色系、边框、对齐、数字格式),新增内容必须与之对齐。严禁对已有风格的文件强行施加通用标准化格式。 +- **扩展而非覆盖**:新增行列或追加数据时,目标是"扩展原模板"——继承邻近区域的表头风格、条纹节奏、边框层级、对齐方式、数字格式和列宽/行高策略。 +- **美化只动样式属性,不动数据**:对**已有区域**做美化时,**只能**修改 `font` / `fill` / `border` / `alignment` / `number_format` 这 5 类样式属性。**禁止**改动原始单元格的 `value` / `formula`、合并区域、行列结构、Sheet 名称。如果美化需求需要改变数据布局(例如"汇总行加进表里"),必须把"加汇总行"和"美化"拆成两步,前者属于编辑动作、需另行得到用户授权。 +- **不可见视觉属性也属保护对象**:原表的**合并范围、对齐方式(H-Align/V-Align)、行高列宽、数字格式**是用户能感知但不一定会明示的视觉属性。即使用户没说"保留这些",**禁止**因写入新内容而修改它们;写公式 / 写值 / 写新列时只传 `value` / `formula`,不要重置 `alignment` / `number_format` 等字段为默认值(重置等同于改动)。**例外**:用户明示要修改这些属性时(如"调整对齐 / 合并 / 列宽")才能动。 +- **美化范围必须覆盖所有用户语义目标**:用户说"给表格加边框 / 美化整个表"时,范围 = 实际数据区域**含所有数据行**(含汇总行、总计行、表尾备注行),不能停在"看起来主体内容结束"的地方。落地前先用 `current_region` + 末尾 5~10 行核对真实末行(同 `lark-sheets-read-data` 的「确定数据范围的正确流程」),再设置美化范围。范围漏掉用户提到的目标行 / 列**直接判失败**。 + +## 美化任务 5 维度 checklist(用户说"美化 / 整理 / 让表更清晰 / 适合打印"时必做) + +当用户用"美化 / 整理表格 / 让表清晰 / 适合打印 / 调整样式"等口语表达**主动美化需求**时,**必须**遍历以下 5 个维度逐一落地,**只动一处就交付**(如只加边框)属于违规: + +1. **表头格式区分**:表头行加粗 + 背景色填充(与数据行有色差)+ 居中对齐;多行表头时全部行同步处理 +2. **对齐方式**:文本列左对齐、数值 / 货币 / 百分比列右对齐、日期 / 分类列居中;垂直方向统一居中 +3. **数值格式**:每列统一小数位 + 千分位(用 `number_format`);金额列统一货币符号;同一列内**禁止**出现 0 位 / 1 位 / 2 位小数混杂 +4. **边框**:覆盖范围按上方「美化范围必须覆盖所有用户语义目标」规则(含汇总 / 总计 / 表尾说明行),内外框线清晰 +5. **列宽 + 行高 + 自动换行**:详细规则见 `lark-sheets-range-operations` 的「写入后列宽自适应」章节(按最长字符数扩列宽 / 长文本设置 `cell_styles.word_wrap="auto-wrap"` + 调高行高 / 长数字设置 `number_format` 防科学计数法) + +**差异化标注场景**:用户要求"重复行 / 异常值 / 重要项视觉区分"时,标注列 / 行必须设置与普通数据**显著不同**的 `cell_styles`(背景色 + 加粗 + 字体色至少改一项),不能与普通数据格式完全一致。 + +## 通用样式规范 + +> 以下取值标准都在「最高优先级原则」的**继承原表风格 / 扩展而非覆盖**前提下生效:凡涉及"沿用原表"的条目,遵循该原则即可,本节不再逐条复述。 + +### 1. 表头样式 + +- 表头/汇总行须与数据区域有明确视觉区分。 +- 使用低饱和度背景色搭配字体颜色(如深蓝 + 白字,浅蓝 + 黑字),文字加粗、水平居中。 +- 表头覆盖多列时使用合并单元格。 + +### 2. 数据区域样式 + +- 减少垂直线条,优先使用水平浅灰细线。 +- **对齐方式**:文本左对齐,数值/货币/百分比右对齐,日期或分类居中,所有内容垂直居中。 +- 次要信息(备注、次要日期等)使用缩小字号或浅灰色。 +- **Zebra Stripes**:数据行 > 10 行时可使用交替背景色引导视线。 + - 设置前先清理原区域背景色为白色(#FFFFFF),再设置斑马纹色,避免新旧混杂。 + - 优先直接设置单元格背景色,而非条件格式(除非用户要求)。 + - 推荐配色:奇数行 #FFFFFF,偶数行 #F3F4F6 或 #EBF1F8。 + +### 3. 数值格式 + +- 百分比使用 `%` 符号,适当注明单位和货币符号(¥、$)。 +- 大于 1000 的数字使用千分位符,保留一致的小数位数(1–2 位)。 +- 涉及数据检索的须注明数据来源。 +- 可使用数据条/色阶/条件格式增强可视化。 + +### 4. 整体结构 + +- 长表/宽表考虑冻结行列,方便滚动查看。 +- **长文本处理**:启用自动换行,行高合理调整以确保阅读舒适,添加适当垂直留白,目标是清晰、专业、不拥挤的布局。 +- 保持表格简洁,合理分组(可用合并单元格展示分组),在适当位置添加合计或汇总行。 +- **区域分隔**:多阶段或多类别时,使用柔和背景色块进行逻辑分区,而非简单边框。 +- **增删行列的样式规则**: + - 新增整列继承同组列的表头样式、列宽、对齐和数字格式;新增整行继承同层级数据行或汇总行风格,避免写成表头风格。追加列时需判断是否应加入已有合并单元格(常见于顶部标题行)。 + - 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。 + - **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。 + - 具体采样与复制流程见下方「场景二:从已有区域继承美化」。 +- **列宽调整**(飞书 `+rows-resize / +cols-resize` 按 pixel 传值): + - 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。 + - 经验估算:中文每字约 15-18px,英文/数字每字约 7-9px,外加 10-16px padding。 + - 上下限建议 80~400px;超上限启用自动换行(`word_wrap: auto-wrap`)+ 调整行高,而非无限加宽。 + - 合并单元格不参与列宽计算,避免撑宽单列。 + - 复制自原文件的列优先沿用原列宽,不重新计算覆盖。 + +### 5. 配色 + +- 优先沿用原表色板与明暗层级(见「继承原表风格」),新增区域不凭空换色,确保视觉连续。 +- 背景填充选择柔和色(如浅蓝 `#DDEBF7`),区分颜色时优先同一主题色不同深浅,避免超过 3 种主题色。 + +### 6. 图表展示 + +- 遵循用户指令选择图表类型,或匹配用户意图(饼图/环形图 → 占比,折线图 → 趋势)。 +- 包含必要元素:标题、图例、数据标签、坐标轴标题。 +- 调整至合适大小,避免数据和标签过多堆叠。 +- **图表放置防重叠**:新增图表前须计算放置区域,避免与已有图表重叠。具体步骤: + 1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`row` 行索引、`col` 列索引如 "A"/"B")、`offset`(锚点内偏移:`row_offset`、`col_offset`,单位像素)以及 `size`(`width`、`height`,单位像素)。 + 2. 获取工作表的行高和列宽信息(像素)。 + 3. 根据每个图表的锚点 `position.row`/`position.col` + 偏移 `offset.row_offset`/`offset.col_offset` + 尺寸 `size.width`/`size.height`,结合行高列宽,计算出每个已有图表覆盖的像素矩形区域 `(x_min, y_min, x_max, y_max)`。 + 4. 为新图表选定大小后,候选放置位置应避开所有已有矩形区域;若存在重叠则向下或向右偏移,直至找到无冲突位置。 + 5. 若工作表已无足够空间,优先向下方空白区域放置,保持图表间至少 1 行或 1 列的间距。 + +> 飞书表格中颜色需带 `#` 前缀(如 `#0070C0`),与 openpyxl 的无前缀写法不同。 +> 具体工具调用参数格式,请读取对应工具 skill(`lark-sheets-write-cells`、`lark-sheets-conditional-format`、`lark-sheets-range-operations` 等)。 + +--- + +## 场景化操作指南 + +### 场景一:新增独立样式 + +> 适用情况:在表格中创建全新的、具有独立视觉特征的区域,如汇总行、新表头、独立数据表等。 + +#### 1A. 添加汇总行 / 表头行 + +**决策流程:** +1. 先用 `+cells-get` 读取目标位置上方的数据区域,确认数据边界和已有样式(背景色、字体大小等) +2. 如果需要新增空行,先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行 +3. 用 `+cells-set` 写入汇总公式 + 特殊样式(背景色区分 + 加粗 + 边框) +4. 如果汇总行标题需要跨列显示,追加 `+cells-{merge|unmerge}` 合并标题区域 + +**样式要点:** +- 汇总行使用比数据区域更深的同色系背景(如数据区 #EBF1F8 → 汇总行 #D6E4F0 或 #4472C4 + 白字) +- 必须加粗,水平对齐方式与数据列一致(数值列右对齐,文本列左对齐) +- 上方加一条较粗的边框线,与数据区域形成视觉分隔 + +#### 1B. 添加独立数据表/独立区域 + +**决策流程:** + +1. 新建 sheet,或用 `+cells-get` 或 `+workbook-info` 确认已有表格的占用范围,找到空闲区域 +2. 用 `+cells-get` 采样已有表格的表头样式(背景色、字体大小、字重、对齐方式)和数据区域样式 +3. 新表头复用已有表头的配色和字体参数(保持风格统一),但内容和列宽可独立 +4. 新数据区域复用已有数据区域的对齐规则、边框风格、数字格式 +5. 用 `+cells-set` 一次性写入新表头 + 数据 + +**样式要点:** +- 必须复用:背景色色系、字体大小、字重、边框风格 +- 可以独立:列宽、行高、具体数字格式(根据新数据的类型调整) +- 新旧表格之间至少留 1~2 行空白作为视觉分隔 + +### 场景二:从已有区域继承美化 + +> 适用情况:新增的行/列/区域与已有内容性质相同(数据类型、层级一致),需要无缝衔接已有格式。 + +#### 2A. 继续补充行/列(数据性质与已有内容一致) + +**核心规则**:采样紧邻 2 行 → 判断并延续 Zebra Stripes 奇偶性 → 按 write-cells 的继承清单带齐样式写入。 + +**斑马纹延续要点**(本节只管"奇偶判断"这一标准,"带哪些样式字段写入"的机制见下方指针): + +- 至少读 2 行(末行 + 倒数第二行)才能判断是否有斑马纹交替色 +- 若倒数两行背景色不同(如 #FFFFFF 与 #F3F4F6),新行按奇偶延续,不要固定一个色 + +> 具体继承哪些字段、怎么采样与写入(`+cells-get` 读源行 `cell_styles` + `border_styles`、`+sheet-info --include row_heights,merges` 读行高合并、带齐 6 类样式写入)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」章节——`border_styles` 四边最易遗漏,以那里为准。 + +#### 2B. 基于模板区域的修改(copy 保留所有格式) + +**核心思路:三步分层法** + +``` +Step 1 — 格式铺开:`+batch-update` + `+range-copy`(或 `+range-fill`) + └── 将模板行/区域的 **全部格式**(样式、边框、数字格式、数据验证等)复制到目标区域 + └── 推荐用 `+range-copy --paste-type formats`(仅复制格式,目标值/公式保留),即"格式刷" + └── 若需连带公式平移填充(如公式列结构一致),改用 `+range-fill --series-type copy` 或 `+range-copy --paste-type all` + +Step 2 — 内容覆写:`+batch-update` + `+cells-set`(仅传 value/formula,不传任何样式) + └── 将每行的实际数据写入,cell_styles 全部省略,因为格式已在 Step 1 中就位 + +Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+cells-{merge|unmerge}` 等 + └── 调整行高列宽、处理合并单元格、扩展条件格式范围等边缘情况 +``` + +**关键注意事项:** +- Step 1 用 `+range-copy --paste-type formats` 时只铺格式、不动值/公式,Step 2 再用 `+cells-set` 写值即可(`+cells-set` 默认覆盖,无需额外 flag);若 Step 1 用 `--paste-type all` 连带复制了值/公式,Step 2 写入同样会覆盖(默认行为) +- `+range-fill --series-type auto`(或 `linear`/`date`)会自动递增数字序列(1→2→3)和日期序列,`+range-fill --series-type copy` 则原样复制值但公式引用会自动平移 +- 如果模板区域存在合并单元格,copy/fill 不会复制合并状态,必须在 Step 3 中用 `+cells-{merge|unmerge}` 补全 +- 如果模板区域有条件格式,需要在 Step 3 中通过 `+cond-format-update` 扩展 ranges + +**场景:纯"格式刷"(用户说"把 A 列样式应用到 B 列"、"格式复制过去"、"只刷格式不改数据")** + +单步即可,无需三步分层:调用 `+range-copy --paste-type formats`,`--source-range` 为样式来源、`--target-range` 为目标起点。参数细节见 `lark-sheets-range-operations`。 + +### 场景三:已有区域格式美化 + +> 适用情况:对已存在数据的区域进行格式美化(不改变数据内容),重点处理表头、汇总行等特殊行的识别与格式设置,需特别注意合并单元格的安全操作。 + +#### 整体操作流程 + +``` +1. 探查阶段 + ├── `+workbook-info` → 获取子表列表、行列数、冻结位置 + ├── `+sheet-info --include merges` → 获取合并区域 + ├── `+cells-get`(前几行 + 末尾几行,`--include style`)→ 采样表头/数据区/汇总行样式 + └── 分析结果 → 建立区域地图(表头行号、数据起止行号、汇总行号、合并区域列表) + +2. 规划阶段 + ├── 判断表头行:通常第 1 行或前 2 行,特征为加粗/背景色/合并/居中 + ├── 判断汇总行:通常最后 1~2 行,特征为加粗/SUM/AVERAGE 公式/更深背景色 + ├── 判断合并区域:从 `+cells-get` 返回中识别(多个单元格同值且样式相同通常暗示合并) + └── 制定美化方案:按区域分别设置样式 + +3. 执行阶段(按顺序) + ├── 先处理合并单元格(如需取消合并再重新合并,必须先 unmerge 再 merge) + ├── 设置表头样式 + ├── 设置数据区域样式 + ├── 设置汇总行样式 + └── 调整列宽行高 +``` + +#### 美化中的合并单元格要点 + +- 编辑前先识别已有合并区域(见探查阶段),避免破坏原有语义分区。 +- 美化表头/分组标题时,若需修改合并区域的范围或样式,遵循"先 `unmerge` → 修改 → 再 `merge`"顺序。 +- 合并区域样式只写左上角,不要对合并内的其他单元格重复写入样式。 + +> 合并单元格完整的安全操作规则(含数据保护、样式占位等 5 条)见 `lark-sheets-range-operations` 的 `+cells-{merge|unmerge}` 章节。 + diff --git a/skills/lark-sheets/references/lark-sheets-workbook.md b/skills/lark-sheets/references/lark-sheets-workbook.md new file mode 100644 index 000000000..7b9d03b4a --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-workbook.md @@ -0,0 +1,197 @@ +# Lark Sheet Workbook + +## Sheet 结构变更保守化(编辑类任务必做) + +`+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` 会改变原表的物理结构,是高副作用动作。执行前必须遵守: + +1. **删除 / 重命名 / 隐藏 / 移动原 Sheet 需用户明示**:除非用户明示要这些操作,**禁止**擅自对**已存在**的 Sheet 执行 delete / rename / hide / move。新建 Sheet 是允许的(用于承载中间结果或透视表 / 图表对象),但应优先在原表右侧加列;只有当中间结果数量较大或会与原数据混淆时,才新建空白 Sheet(同 R1)。 +2. **Sheet 级操作前先列清单**:调用 `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` 之前,必须先调用 `+workbook-info`,把"当前所有 Sheet 名 + 可见性 + 行列数"列出来,再决定是否操作。禁止跳过列清单直接 create / delete / rename。 +3. **删除 / 重命名前向用户确认**:删除是不可逆的,重命名会让其他公式 / 透视表 / 图表的数据源失效——执行前必须在回复里确认"将删除 / 改名 X,影响 Y 个引用"。 + +## 使用场景 + +读写。管理工作簿结构。本 reference 覆盖 11 个 shortcut: + +| 操作需求 | 使用工具 | 说明 | +|---------|---------|------| +| 查看工作簿结构 | `+workbook-info` | 获取子表列表、名称、行列数、冻结位置等元数据 | +| 变更工作簿结构 | `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` | 新建/删除/移动/重命名/复制/隐藏子表、修改标签颜色 | + +注意: + +- 如果用户请求包含多个动作,例如"先重命名,再新建工作表",请按顺序发起多次调用,覆盖全部动作 +- `create` 时若用户指定了工作表名称,应显式传入 `sheet_name`;不要省略后依赖默认命名 +- 若 `+workbook-info` 返回包含 `warning_message`,说明部分 `sheet_id` 已失效(被删除/改名或输入错误),应停止复用这些 id,重新不带 `sheet_ids` 全量获取结构后再继续操作 + +**常见配置错误(必须注意)**: +- **获取结构是第一步**:任何表格操作前必须先调用 `+workbook-info`,不要跳过直接操作。返回的行列数、子表列表是后续所有操作的基础 +- **sheet_id 不要写错**:从 `+workbook-info` 返回值中精确获取 `sheet_id`,不要手动拼写或从 URL 中猜测 +- **优先使用 `sheet_id`**:虽然飞书表格不允许子表重名,但 `sheet_id` 是稳定标识符,跨多轮操作时不会因用户中途重命名而失效 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+workbook-info` | read | 工作簿 | +| `+sheet-create` | write | 工作簿 | +| `+sheet-delete` | high-risk-write | 工作簿 | +| `+sheet-rename` | write | 工作簿 | +| `+sheet-move` | write | 工作簿 | +| `+sheet-copy` | write | 工作簿 | +| `+sheet-hide` | write | 工作簿 | +| `+sheet-unhide` | write | 工作簿 | +| `+sheet-set-tab-color` | write | 工作簿 | +| `+workbook-create` | write | 工作簿 | +| `+workbook-export` | read | 工作簿 | + +## Flags + +### `+workbook-info` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +_仅含公共 / 系统 flag。_ + +### `+sheet-create` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--title` | string | required | 新工作表名称 | +| `--index` | int | optional | 插入位置;省略时附加到末尾 | +| `--row-count` | int | optional | 初始行数(默认 200,上限 50000) | +| `--col-count` | int | optional | 初始列数(默认 20,上限 200) | + +### `+sheet-delete` + +_公共四件套 · 系统:`--yes`、`--dry-run`_ + +_仅含公共 / 系统 flag。_ + +### `+sheet-rename` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--title` | string | required | 新名称 | + +### `+sheet-move` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--index` | int | required | 目标位置(0-based) | +| `--source-index` | int | optional | 源位置(0-based);可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生 | + +### `+sheet-copy` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--title` | string | optional | 副本名称;省略时由服务端生成 | +| `--index` | int | optional | 副本插入位置(0-based);省略时附加到末尾 | + +### `+sheet-hide` + +_公共四件套 · 系统:`--dry-run`_ + +_仅含公共 / 系统 flag。_ + +### `+sheet-unhide` + +_公共四件套 · 系统:`--dry-run`_ + +_仅含公共 / 系统 flag。_ + +### `+sheet-set-tab-color` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--color` | string | required | Hex 色值如 `#FF0000`,传空 `""` 清除 | + +### `+workbook-create` + +_系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--title` | string | required | 新 spreadsheet 标题 | +| `--folder-token` | string | optional | 目标文件夹 token;省略时放在云空间根目录 | +| `--headers` | string + File + Stdin(简单 JSON) | optional | 表头行 JSON 数组:`["列A","列B"]` | +| `--values` | string + File + Stdin(简单 JSON) | optional | 初始数据 JSON 二维数组:`[["alice",95]]` | + +### `+workbook-export` + +_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--file-extension` | string | optional | 导出文件格式;`csv` 模式必须配 `--sheet-id`(可选值:`xlsx` / `csv`)(默认 `xlsx`) | +| `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出哪张 sheet 为 CSV。这是 `+workbook-export` 专有 flag,与公共四件套的 sheet 定位无关(本 shortcut 不接受公共 sheet 定位) | +| `--output-path` | string | optional | 本地保存路径;省略时只触发导出不下载 | + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id` 或 `--sheet-name`。 + +### `+workbook-info` + +输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`)/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。 + +### `+sheet-create` + +示例: + +```bash +lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \ + --title "汇总" --index 0 +``` + +### `+sheet-delete` + +> ⚠️ 工作表删除不可逆;先 `--dry-run` 看输出 sheet_id + title 确认是要删的那张。 + +### `+sheet-rename` + +```bash +lark-cli sheets +sheet-rename --url "..." --sheet-id "$SID" --title "汇总" +``` + +### `+sheet-move` + +standalone 路径在缺 `--source-index` / 只给 `--sheet-name` 时会自动发起一次 `+workbook-info` 读把它们解出来。 + +> ⚠️ **在 `+batch-update` 内调用 `+sheet-move`**:必须同时显式传 `--sheet-id`、`--source-index` 和 `--index`(目标位置)。batch 中途无法发起结构查询,且 `--index` 不显式给会静默落到默认位置 0,所以 batch translator 强制要求三者都显式。 + +### `+sheet-copy` + +```bash +# --title 省略时由服务端生成副本名 +lark-cli sheets +sheet-copy --url "..." --sheet-id "$SID" --title "副本" +``` + +### `+sheet-hide` / `+sheet-unhide` + +```bash +lark-cli sheets +sheet-hide --url "..." --sheet-id "$SID" +lark-cli sheets +sheet-unhide --url "..." --sheet-id "$SID" +``` + +### `+sheet-set-tab-color` + +```bash +# Hex 色值;传空字符串 "" 清除标签色 +lark-cli sheets +sheet-set-tab-color --url "..." --sheet-id "$SID" --color "#FF0000" +``` + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`。 +- `DryRun`:`+sheet-*` 写操作输出"将要 PATCH 的 sheet metadata";`--sheet-name` 在 dry-run 输出里生成为 `` 占位符,不实际解析为 sheet-id。 +- `Execute`:写操作不自动回读;如需确认目标 sheet 的新状态,自行调用 `+workbook-info`。 diff --git a/skills/lark-sheets/references/lark-sheets-write-cells.md b/skills/lark-sheets/references/lark-sheets-write-cells.md new file mode 100644 index 000000000..dd772f246 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-write-cells.md @@ -0,0 +1,437 @@ +# Lark Sheet Write Cells + +## 写入边界 + 回读校验(编辑类任务必做) + +1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。 +2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。 +3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。 + +## 新增列 / 新增行的样式继承(防止视觉风格不一致) + +新增列 / 新增行**必须**先用 `+cells-get` 读相邻原列 / 原行的完整样式作为模板,**禁止**只传 `value` 期望默认样式与原表一致——飞书新单元格默认对齐通常是 `H:right, V:bottom`,与多数原表的 `H:center, V:middle` 不一致。 + +**完整继承清单**(写新列 / 新行时 cells 数组必须同时携带): + +1. `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`(字号 / 粗细 / 颜色 / 斜体等) +2. `cell_styles.horizontal_alignment` / `cell_styles.vertical_alignment`(H-Align / V-Align)—— 漏继承会导致新列对齐与原列不一致(高频) +3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱 +4. `cell_styles.background_color`(背景色) +5. `border_styles`(四边框) +6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --include merges` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致) + +**采样模板的正确做法**: +- 表头新列 → 读相邻表头单元格(如新加 D1 → 读 A1/B1/C1 任一) +- 数据新列 → 读相邻数据行单元格(如新加 M5:M100 → 读 L5 / L6 / L7) +- 续写新行 → 读最近一行已有数据(如续写第 20 行 → 读 19 行所有列) + +**反模式**(违规): +- 只传 `{"value": "四级菜单"}` 给 D1,不传 `cell_styles` → D1 默认非加粗、非居中,与 A1/B1/C1 风格断裂 +- 新列 M5 写入 `=SUM(F5:L5)` 时只传 `formula`,不传 `cell_styles.horizontal_alignment / vertical_alignment / number_format` → M 列对齐变 `H:right`,数字格式变默认 + +## 长数字防科学计数法(数值列写入必查) + +写入或计算结果可能产生长数字(≥ 12 位整数 / 高精度小数)的列,**必须**在 `cell_styles.number_format` 显式设置非通用格式,否则飞书会自动用科学计数法显示,用户看到的就是"内容被截断 / 看不清原值"。 + +| 场景 | 必加的 `number_format` | +|---|---| +| 长整数(订单号 / 身份证 / 单据号) | `"0"` 或 `"@"`(强制文本,避免精度丢失) | +| 金额 / 千分位 | `"#,##0.00"` | +| 百分比 | `"0.00%"` | +| 数量 / 计数 | `"0"`(整数) | +| 日期 | `"yyyy-mm-dd"` 或 `"yyyy/m/d"` | + +**典型反例**:长数字列(如审批单号、流水号)未设 `number_format`,飞书显示为 `1.23E+15`,用户复制出来已经丢失精度。 + +## 使用场景 + +写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `--copy-to-range` 字段复制,可大幅减少 `cells` 长度。** + +> **单元格图片 vs 浮动图片**: +> - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。 +> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark-sheets-float-image。 + +高频模式(**必须遵守,禁止逐行写入替代**): + +- 整列公式:先在 `H2` 写一个公式,再用 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式** +- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `--copy-to-range "J:J"` +- 首行样式:先在 `A1` 写一个模板单元格,再用 `--copy-to-range "1:1"` +- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `--copy-to-range` +- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制到所有目标区域 + +⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。 + +💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。 + +💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步: +1. **先写内容**:`+cells-set` 只传 `value` / `formula`,不带样式,`cells` 入参精简 +2. **再批量刷样式**:对区域中的一个单元格写入目标样式作为模板,再用 `--copy-to-range` 将样式扩展到整列 / 整行 / 整个区域(`--copy-to-range` 会复制值、公式和样式,所以模板单元格应已包含正确的值) + +示例:要对 A2:A100 写入数据并统一设置蓝色背景 + 边框: +``` +Step 1: `+cells-set` — range="A2:A100", cells 只含 value(无样式,入参短) +Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styles(单个模板), --copy-to-range="A2:A100" +``` +这比在 99 个单元格中都重复写样式 JSON 高效得多。 + +💡 **样式更新是「部分合并」,不是整体覆盖**:`+cells-set-style` / `+cells-batch-set-style`(以及 `+cells-set` 的 `cell_styles` / `border_styles`)只改你**显式传入**的样式属性,未传的属性保留原值。两个实用推论: +- **可分层叠加**:对同一区域先刷字体色、再单独刷背景色、再单独刷边框,后一步不会清掉前一步——美化已有区域时无需一次带齐所有字段,可拆成多次窄调用。 +- **`border_styles` 按边合并**:只传 `{"top":{...}}` 只更新上边框,`bottom` / `left` / `right` 保留原状;不必为了「只改一条边」而把四边全部重传。(例外见上方「新增行的边框/样式禁止用 `{}` 跳过」:**全新行**底子里没有边框,仍需把要显示的边都显式传出。) + +💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会让单次生成的内容过长,容易出错或被截断。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次只生成当前批次的数据,控制单次生成量。 + +注意: + +- 不要把 `cells` 写成字符串化 JSON +- `+cells-set` 默认即覆盖非空 cell(`--allow-overwrite` 默认 true);若要**保护**非空 cell 不被覆盖,显式传 `--allow-overwrite=false`(遇非空 cell 报错) +- 若目标区域涉及合并单元格,不要向合并区域中的非左上角单元格写入数据;如需写入,应改写合并区域左上角单元格,或先调整/取消合并区域 +- **构造 `range` 时行号必须基于逻辑行号**:如果之前通过 `+csv-get` 读取了数据,CSV 中被双引号包裹的多行字段(如 `"2026年3月2日\n星期一"`)是**一个单元格**,不是两行。写入时的行号必须按逻辑记录计算,不能按物理换行符计数,否则 `range` 会整体偏移导致写入到错误位置 + +> 用户说"样式和原表一致 / 保持原表格式 / 边框继承"时同理:`cell_styles` 只覆盖字体和对齐、**不含边框**,边框必须用独立 `border_styles` 字段传——完整继承清单见上方「新增列 / 新增行的样式继承」。 + +⚠️ **公式写入必须自己校验结果(后端不会报语法错)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(REGEXEXTRACT(D5, "\d+"))), 0)` 比 IFERROR 多一个 `)`)或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / `CUBEVALUE`),**后端工具也会返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此: +1. **写完立即读回**:`+cells-set` 后紧跟 `+csv-get`(或 `+cells-get`)读目标范围前几行,检查是否出现 `#VALUE!` / `#NAME?` / `#REF!` / `#N/A` / `#DIV/0!` / `#NUM!` +2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / CUBE 系列;注意 `UNIQUE` / `FILTER` / `SPLIT` 飞书是支持的);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环) +3. **`--copy-to-range` 扩展前先验证模板**:模板单元格公式自己都算错,`--copy-to-range` 复制到 100 行就是 100 个错误 +4. **去重 / 筛选函数**:飞书**支持** `UNIQUE` / `FILTER` / `SPLIT`(原生数组函数,详见 `lark-sheets-formula-translation`),可直接用;`DISTINCT` 不是飞书函数,去重用 `UNIQUE`。大数据量去重 / 分组也可用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count) +5. **循环引用预检(高频致命错误)**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己 +6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用本地脚本在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配) +7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规 + +⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 的下标取值飞书不支持(SPLIT 本身支持,取第 N 项用 `INDEX(SPLIT(...),N)`);`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须: + +1. **重新审视整条公式的完整性**:被 formula_errors 标出的那一行,公式除了下标语法错,还可能有其他先天缺陷(字符清洗不全、IFERROR 兜底漏条件、引用列写错),修完语法错后立即整体复核 +2. **同步对称修复所有相似列**:如果同一任务涉及多列相似处理(如"算 H 列面积"用 D 列尺寸、"算 I 列面积"用 E 列尺寸),**修完一列必须把同样的清洗/兜底逻辑同步到所有相似列**,禁止出现 H 列用 `SUBSTITUTE(长)+SUBSTITUTE(高)+SUBSTITUTE(×)` 而 I 列只用 `SUBSTITUTE(×)` 这种不对称处理——会导致一列编译通过有值、另一列编译通过但 IFERROR 全返回空,用户看到的是"数据为空"而非"公式错" +3. **修完再读回验证**:不只看 `formula_errors` 为空(这只证明编译通过,不证明运行时有值),必须 `+csv-get` 读目标列前 3-5 行,确认**非空源数据对应的目标列有非空计算结果** +4. **核心心智**:`formula_errors` 是"帮你暴露编译错"的工具,不是"修掉它就收工"的通行证。编译通过 + 运行时 IFERROR 兜底空 = 用户视角的"没算出来" + +⚠️ **新增行的边框/样式禁止用 `{}` 跳过(高频致命错误)**:`cells` 数组里 `{}` 的语义是"**此单元格不做任何修改、保留原状态**"。这在写入**已有行**时是安全的(原有边框/样式保持不变),但在写入**新行**(比如表尾追加汇总行、扩展行)时是灾难:新行底子里本来就没边框,`{}` 不修改 = 保留无边框状态,导致该 cell 视觉断裂。 + +⚠️ **"汇总行"识别 → 读 `lark-sheets-visual-standards` 拿完整样式规范**:下述双重条件**同时满足**才是汇总行,禁止仅凭"有 AVERAGE"就判定: +- **语义信号**(二选一):用户 prompt 含"合计/汇总/总计/统计/各科平均分/最下面加一行算…/底部总计"等意图词;或上下文明确是"表尾追加一行做聚合" +- **结构信号**:新行全行都在做聚合(含 `=SUM/AVERAGE/COUNT/MAX/MIN/SUBTOTAL(...)`,支持 IFERROR 包裹),**不是**单个 cell 算个参考值或每行都算的派生列 + +满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」章节,按那里的样式要点配齐 `font.bold / horizontal_alignment / background_color / border_styles`。 + +反例(**不是**汇总行,禁止自动加粗): +- 用户说"在 H5 帮我算个 AVERAGE 参考"→ 单 cell 计算 +- 每行都有 `=AVERAGE(本行区间)` 的派生列 → 属数据列 +- 用户明确说"不要加粗/样式和数据行保持一致"→ 遵循用户意图 + +**正确做法**(二选一): + +- **做法 A(推荐)**:按上方「内容与样式分离写入」两步法——先用模板单元格 + `--copy-to-range` 铺**完整样式**(`cell_styles` + `border_styles` 都要,不能只铺 border,否则新行字体 / 对齐 / 背景色全裸奔),再单独 `+cells-set` 写 value / formula。汇总行的 `cell_styles` 要点(bold / 背景色 / 上边框)见 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」。 +- **做法 B**:一次写入,但每个 cell(含空白格)都显式带 `cell_styles` + `border_styles`,**不能用 `{}`**。 + +**判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,必须按上述做法补边框。 + +## 富文本单元格:超链接 / @人 / @文档(`rich_text`) + +带显示文本的超链接、@人、@文档这类富内容**必须**走 `+cells-set` 的 `rich_text` 字段(`cells[].rich_text` 数组,每段一个对象、带 `type`),**不能**直接传普通字符串——纯字符串只会被当作纯文本存进单元格。完整字段跑 `lark-cli sheets +cells-set --print-schema --flag-name cells`,常用段类型: + +- **超链接(带显示文本)**:`{"type":"link","text":"飞书","link":"https://www.feishu.cn"}`。纯 URL 不需要 `rich_text`,直接写普通字符串即可。 +- **@人**:`{"type":"mention","mention_token":"","notify":false}`。**仅支持同租户用户,单次写入最多 50 人。** `notify` **默认 `true`**(会给被 @ 的人发通知),不想发务必显式传 `false`。 +- **@文档**:同样 `"type":"mention"`,`mention_token` 传文档 token(如 `shtXXX`)。 + +`mention_type`(类型编号)等可选字段以 `--print-schema` 输出为准。 + +> ⚠️ `rich_text` 一旦设置会**忽略**同一 cell 的 `value`;它与 `formula` / `multiple_values` 三者只能选其一作为内容字段(可叠加 `cell_styles` / `note` 等)。 + +## Dropdown 选项 + 配色(`+dropdown-set` / `+dropdown-update`) + +### 选项怎么来:`--options` 与 `--source-range` 二选一 + +| flag | 选项来源 | 适用场景 | +|---|---|---| +| `--options '["a","b","c"]'` | 写在命令里的固定列表 | 选项集是常量、不需要事后维护 | +| `--source-range ''\''Sheet1'\''!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 | + +两个 flag **必须传一个、且只能传一个**——同时传或都不传,CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `'Sheet1'!T1:T3`,sheet 名按 A1 标准单引号包裹),可以指同 sheet 也可以指其它 sheet(如 `'Refs'!A1:A10`)。 + +### 配色:默认即上色,三种意图三条线 + +下拉**默认带胶囊高亮**——什么 flag 都不传时,所有选项按内置 10 色色板循环上色,跟 UI 手动配下拉的默认行为对齐。三种意图: + +| 想要的效果 | 怎么传 | +|---|---| +| 默认色板循环上色 | 都不传 `--highlight` / `--colors` | +| 按选项指定具体颜色 | 只传 `--colors '["#hex",...]'`(不需要再传 `--highlight`) | +| 纯白下拉、不要高亮 | 传 `--highlight=false`(注意 `=false` 不能省,单写 `--highlight` 在 cobra 里等价于 true) | + +`--colors` 长度**可以短于**选项数(list 模式短于 `--options` 长度,listFromRange 模式短于 `--source-range` 的单元格数),未指定的选项按内置色板循环补色;但**不能长于**——CLI 在 Validate 阶段就会拦截,错误形如 `--colors length (4) must not exceed dropdown source size (3)`。 + +当 `--highlight=false` 显式关闭高亮时,`--colors` 即使传了也会被忽略(语义自相矛盾,但不报错)。 + +### 最小用例 + +**`--options` 模式 — 默认色板(最常见)**: + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range A2:A100 \ + --options '["待开始","进行中","已完成","已取消"]' +``` + +**`--options` 模式 — 指定颜色**(4 个选项配 3 个颜色,第 4 个按色板补): + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range A2:A100 \ + --options '["待开始","进行中","已完成","已取消"]' \ + --colors '["#bff7d9","#FFE699","#bacefd"]' +``` + +**`--source-range` 模式**(先在 `'Sheet1'!T1:T3` 维护「男/女/保密」三行,再让 `B2:B21` 引用它): + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range B2:B21 \ + --source-range ''\''Sheet1'\''!T1:T3' \ + --colors '["#cce8ff","#ffd6e7","#e6e6e6"]' +``` + +**纯白下拉**(明确告诉用户"不要彩色"时才用): + +``` +lark-cli sheets +dropdown-set \ + --url https://... --sheet-id \ + --range A2:A100 \ + --options '["低","中","高"]' \ + --highlight=false +``` + +> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet)。注意一个坑:回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`,**必须自己重新补上 sheet 前缀**,否则会被拒。 +> +> ⚠️ **sheet 前缀里的表名一律「裸写」,不要加引号**——这条对所有带 sheet 前缀的 range 入参通用(`--source-range`、`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` 的 `--ranges` 等)。即使表名含点或空格(如 `2025.9`、`一月份 `),也直接写 `2025.9!A1`;**不要**按电子表格习惯写成 `'2025.9'!A1`——引号会被当成表名的一部分,导致 `sheet "'2025.9'" not found`。 + +`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。 + +## 工具选择 + +本 skill 提供以下 CLI shortcut,按数据来源 + 内容形态选: + +| 场景 | 用这个 shortcut | 原因 | +|------|----------------|------| +| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 | +| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut | +| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 | +| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 | +| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 | + +**优先级**:常规纯值写入优先 `+csv-put`(最短入参,直接传 CSV 文本);含公式/样式/批注/图片才用 `+cells-set`。 + +⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。 + +⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。 + +## Shortcuts + +| Shortcut | Risk | 分组 | +| --- | --- | --- | +| `+cells-set` | write | 单元格 | +| `+cells-set-style` | write | 单元格 | +| `+cells-set-image` | write | 单元格 | +| `+dropdown-set` | write | 对象 | +| `+csv-put` | write | 单元格 | + +## Flags + +### `+cells-set` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 写入区域(A1 格式) | +| `--cells` | string + File + Stdin(复合 JSON) | required | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` | +| `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 | +| `--max-cells` | int | optional | 防爆,默认 50000(隐藏 flag:不在 `--help` 列出,但可正常传入) | +| `--copy-to-range` | string | optional | 复制范围(A1 表示法):把 --range 中 --cells 写入的内容(值/公式/样式,取决于实际传入字段)复制到该区域,公式引用自动平移(如 C2=B2 → C3=B3)。适合先写一行/一块模板再扩展填充整列/整区域(如 --range A1:G1 写模板、--copy-to-range A1:G100 填充 100 行)。支持整行 3:6、整列 C:E、到列尾 D3:D、到行尾 D3:3;支持英文逗号分隔多个目标区域,如 C1:D2,E5:F6 | + +### `+cells-set-style` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 目标范围(A1 格式,如 `A1:B2`) | +| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) | +| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) | +| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) | +| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) | +| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) | +| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through`) | +| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right`) | +| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) | +| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) | +| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) | +| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON:`{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`;4 方向结构相同 | + +### `+cells-set-image` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 目标单元格(A1 格式,必须单 cell,如 `A1`;起止 cell 须相同) | +| `--image` | string | required | 本地图片路径(支持 PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC) | +| `--name` | string | optional | 图片文件名(含扩展名);省略时取 `--image` 的 basename | + +### `+dropdown-set` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) | +| `--options` | string + File + Stdin(复合 JSON) | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 | +| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 | +| `--multiple` | bool | optional | 启用多选;默认 `false` | +| `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 | +| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 | + +### `+csv-put` + +_公共四件套 · 系统:`--dry-run`_ + +| Flag | Type | 必填 | 说明 | +| --- | --- | --- | --- | +| `--start-cell` | string | required | 目标区域起点 A1(如 `A1`、`B5`,不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet);必须是单个单元格,不接受范围写法;终点按 CSV 实际行列数自动推断 | +| `--csv` | string + File + Stdin(非 JSON 文本) | required | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 | +| `--allow-overwrite` | bool | optional | 允许覆盖(默认 true);设为 false 时若目标非空报错 | +| `--range` | string | optional | --start-cell 的别名(与 +csv-get / +cells-set 一致,用 --range 定位);传区间(如 A1:H17)时自动取其左上角单元格(隐藏 flag:不在 `--help` 列出,但可正常传入) | + +## Schemas + +> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。 + +### `+cells-set` `--cells` + +_【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]](2行×3列),'B5:B7'→[[_],[_],[_]](3行×1列),'A1'→[[_]](1×1)_ + +**二维数组项**(类型 object): +- `value` (oneOf?) — 静态单元格值(文本、数字、布尔) +- `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)') +- `note` (string?) — 单元格批注/备注 +- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_size?: number, font_weight?: enum, font_style?: enum, font_line?: enum, …共 10 项 } +- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top) { top?: object, bottom?: object, left?: object, right?: object } +- `rich_text` (array?) — 富文本内容 each: { type: enum, text: string, style?: object, link?: string, mention_token?: string, …共 17 项 } +- `multiple_values` (array?) — 多值内容,用于支持多选的列表验证单元格 each: { value: oneOf, format?: string } +- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array, range?: string, operator?: enum, values?: array, …共 9 项 } + +### `+cells-set-style` `--border-styles` + +_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_ + +**顶层字段**: +- `top` (object?) { style?: enum, weight?: enum, color?: string } +- `bottom` (object?) { style?: enum, weight?: enum, color?: string } +- `left` (object?) { style?: enum, weight?: enum, color?: string } +- `right` (object?) { style?: enum, weight?: enum, color?: string } + +### `+dropdown-set` `--options` + +_列表选项_ + +**数组项**(类型 string): +- 标量:string + +## Examples + +公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。 + +### `+cells-set` 的拆分与转介绍 + +"工具选择"段已讲清纯值(`+csv-put`)vs 富写入(`+cells-set`)。下表补 CLI 侧的 `+cells-set` **兄弟拆分**,以及不属于本 skill 的**跨 skill 转介绍**——避免 agent 用 `+cells-set` 硬扛所有写入场景。 + +| 写入场景 | 用这个 | 不要用 | +|---------|--------|--------| +| 只改**已有 cell 的样式**,不动 value/formula | `+cells-set-style` | `+cells-set`(会触发不必要的值写入) | +| 把**单张图片嵌入**到某个 cell | `+cells-set-image` | `+cells-set`(参数更繁琐) | +| **插行/列 + 写入** 这种多步组合,且要原子 | `+batch-update`(跨 skill) | 多次独立 `+cells-set`(非原子;插入会扰动后续 range) | +| 在**多个不连续 range** 上应用同一组样式 | `+cells-batch-set-style`(跨 skill) | 多次 `+cells-set-style`(非原子) | + +### `+cells-set` + +示例: + +```bash +# 纯值(数组形态);默认即覆盖非空 cell,无需显式传 --allow-overwrite +lark-cli sheets +cells-set --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --range "A1:B2" \ + --cells '[[{"value":"name"},{"value":"score"}],[{"value":"alice"},{"value":95}]]' + +# 富 cell(公式 + 样式,cells 是二维矩阵每元素一个 cell schema) +lark-cli sheets +cells-set --spreadsheet-token shtXXX --sheet-id "$SID" \ + --range "C2:C10" --cells @rich-cells.json +``` + +`--cells` 富格式见 `## Schemas` 段(cells 元素含 value / formula / cell_styles / border_styles / data_validation / multiple_values / note / rich_text);值 / 公式 / 样式 / 批注 / 嵌入图片可同一次写入混合提交。 + +> 中间想跳过的 cell 用空对象 `{}` 占位(底层语义为"保留原值不变"),`--cells` 维度仍须与 `--range` 完全一致。例:`--range A1:A5 --cells '[[{"value":1}],[{}],[{}],[{}],[{"value":5}]]'` 只写 A1 和 A5。 +> +> 跨多个不连续区域散点写入(如 `D2` + `F7` + `J15`)不属于 `+cells-set` 的能力范围——请用 `+batch-update` 把多次 `+cells-set` 打包成单次原子请求。 + +### `+cells-set-style` + +只改样式,不动 value / formula。10 个 cell_styles 字段拍平为独立 flag,边框走 `--border-styles` JSON。 + +```bash +# 加粗 + 黄底 +lark-cli sheets +cells-set-style --url "..." --sheet-name "Sheet1" \ + --range "A1:B2" --font-weight bold --background-color "#FFFF00" + +# 配套边框 +lark-cli sheets +cells-set-style --url "..." --sheet-id "$SID" \ + --range "A1:D10" --font-size 12 --horizontal-alignment center \ + --border-styles '{"top":{"style":"solid","color":"#000","weight":"thin"},"bottom":{"style":"solid","color":"#000","weight":"thin"}}' +``` + +### `+cells-set-image` + +把单张图片嵌入 cell(必须单 cell 范围): + +```bash +lark-cli sheets +cells-set-image --url "..." --sheet-name "Sheet1" \ + --range "A1" --image ./logo.png +``` + +### `+csv-put` + +示例: + +```bash +# 内联 CSV +lark-cli sheets +csv-put --url "https://example.feishu.cn/sheets/shtXXX" \ + --sheet-name "Sheet1" --start-cell "A1" \ + --csv $'name,score\nalice,95\nbob,87' + +# 从文件 +lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \ + --start-cell "A1" --csv @data.csv +``` + +> `+csv-put` 比 `+cells-set` 短得多——只想批量灌纯值时优先用它。需要公式/样式才换 `+cells-set`。 +> +> ⚠️ `=` 开头的字符串会被当字面量写入(**不会变公式**): +> +> ```bash +> lark-cli sheets +csv-put --url "..." --sheet-name "Sheet1" \ +> --start-cell "A1" \ +> --csv $'name,score\nalice,=SUM(B2:B10)' +> # ↑ A2 实际写入字符串 "=SUM(B2:B10)",**不是公式**。需要写公式请用 +cells-set。 +> ``` + +> **定位 + 写入边界(关键,避免误覆盖)**: +> - 定位用 `--start-cell`(锚点 = 左上角单元格);也接受 `--range` 别名(与 `+csv-get` / `+cells-set` 一致,传区间会自动取左上角)。 +> - ⚠️ `--start-cell` / `--range` **只定左上角、不限制写入大小**:CSV 从锚点按自身行列数 auto-expand 铺开。给一个"小 range"**不会**截断数据——超出部分照写,且默认覆盖。这与 `+cells-set --range`(精确矩形、`--cells` 必须与 range 同维)语义相反,别把那套心智搬过来。 +> - dry-run 与成功响应都回显 `writes_range`(实际落区,如 `B2:D4`):**写前先 `--dry-run` 看一眼落区**,确认不会盖到相邻数据。 +> - 要保护非空 cell:`--allow-overwrite=false`(落区内出现非空 cell 即报错)。 + +### Validate / DryRun / Execute 约束 + +- `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。 +- `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。 +- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <写入区域> --include value,formula` 抽样核对。