mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
125 Commits
docs/block
...
feat/lazy-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0efde2f901 | ||
|
|
5ac35fd9fd | ||
|
|
1decb4399d | ||
|
|
d05fbcf041 | ||
|
|
cf47d9b5f9 | ||
|
|
2132472b87 | ||
|
|
27c6333620 | ||
|
|
6bd21ac8c9 | ||
|
|
e4309bb5b2 | ||
|
|
d6a0aadbe4 | ||
|
|
da198cf06a | ||
|
|
ad7b20935c | ||
|
|
6e18185eaa | ||
|
|
aec1bd4b0c | ||
|
|
fe1b6b7bbb | ||
|
|
4c24c6eb94 | ||
|
|
bc1cd72074 | ||
|
|
d2aa27dac8 | ||
|
|
3bca545796 | ||
|
|
a8b29a1cf1 | ||
|
|
9c7d5a4b96 | ||
|
|
ce9764ec2e | ||
|
|
fc6e60ba5b | ||
|
|
5aba007f57 | ||
|
|
5c22f912aa | ||
|
|
7dd479df12 | ||
|
|
46066de29e | ||
|
|
e58fa13716 | ||
|
|
f99b5bf32e | ||
|
|
74761a0e1c | ||
|
|
7d24e2b649 | ||
|
|
e3eca666fb | ||
|
|
da65e37647 | ||
|
|
dce617eab2 | ||
|
|
9c3d30aa00 | ||
|
|
690e746896 | ||
|
|
0f695b60ec | ||
|
|
77f86ec2fd | ||
|
|
8e84f47d3e | ||
|
|
5ebb1398e7 | ||
|
|
0476dec83c | ||
|
|
69d2851163 | ||
|
|
ce5878e3c4 | ||
|
|
e85afd68d2 | ||
|
|
a09593a0fe | ||
|
|
60c61d8157 | ||
|
|
08e8b5c870 | ||
|
|
338cdaa6db | ||
|
|
55ccbc5f6a | ||
|
|
bea4c746ae | ||
|
|
4b16fe9ce0 | ||
|
|
f53e55ce65 | ||
|
|
71eae77f65 | ||
|
|
08d025945e | ||
|
|
930c9c77a8 | ||
|
|
bb7ccaedf9 | ||
|
|
a0d6472e9f | ||
|
|
6b3c0b5556 | ||
|
|
f4bcb85d2e | ||
|
|
5880d070e2 | ||
|
|
2082095f18 | ||
|
|
6cadbe807a | ||
|
|
927a73faa2 | ||
|
|
9c447e735b | ||
|
|
12b94746bb | ||
|
|
5327e9390d | ||
|
|
dece428487 | ||
|
|
ff493d4534 | ||
|
|
ff78ff40d8 | ||
|
|
1a2d2d04be | ||
|
|
5eaa70b74a | ||
|
|
f0dea38aeb | ||
|
|
fa503fa47f | ||
|
|
38ef6ad51e | ||
|
|
f0d218f7ea | ||
|
|
09c02e8657 | ||
|
|
2ee2a59dff | ||
|
|
96c338735a | ||
|
|
101c572d64 | ||
|
|
9d06652aa9 | ||
|
|
5926e89ce3 | ||
|
|
556b2292c7 | ||
|
|
4be06c85f6 | ||
|
|
868beaf004 | ||
|
|
81bb61359d | ||
|
|
370137e1c3 | ||
|
|
48e6072342 | ||
|
|
b85311c873 | ||
|
|
2f5c625ac7 | ||
|
|
e0c22d6ee0 | ||
|
|
efcc55460b | ||
|
|
e003d4aa01 | ||
|
|
d91341bca3 | ||
|
|
5a42fb5788 | ||
|
|
d914c851ac | ||
|
|
300a5e8906 | ||
|
|
5f3e8c6385 | ||
|
|
8e8a5110ee | ||
|
|
4e44e668f7 | ||
|
|
8d0fefd9e0 | ||
|
|
1e05e7b3ad | ||
|
|
0ea7c14e4a | ||
|
|
0c2e5f5e5c | ||
|
|
9048c7097f | ||
|
|
3d3e2c7f10 | ||
|
|
ce852e26d8 | ||
|
|
460c794f28 | ||
|
|
54914e6082 | ||
|
|
50190e8638 | ||
|
|
184949ff0c | ||
|
|
347d80361d | ||
|
|
be31975f7e | ||
|
|
2acff2b17f | ||
|
|
b9a1752095 | ||
|
|
96f5742511 | ||
|
|
9898024392 | ||
|
|
705844f312 | ||
|
|
1cbc049700 | ||
|
|
ee4096f141 | ||
|
|
b3e99de06c | ||
|
|
0d351179e4 | ||
|
|
8494534c8f | ||
|
|
ae728fe7ec | ||
|
|
b33a06c1e4 | ||
|
|
17a5f29306 |
@@ -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) {
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
70
cmd/flag_suggest_test.go
Normal file
70
cmd/flag_suggest_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
61
cmd/notice_test.go
Normal file
61
cmd/notice_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
284
cmd/root.go
284
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.
|
||||
@@ -83,7 +86,15 @@ COMMUNITY:
|
||||
More help: lark-cli <command> --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)
|
||||
@@ -147,29 +158,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 len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
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 dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
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.
|
||||
@@ -267,6 +298,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
|
||||
}
|
||||
@@ -308,6 +352,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{}
|
||||
}
|
||||
@@ -327,14 +377,51 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
// A bare group (e.g. `sheets`) legitimately prints help. But an unknown
|
||||
// flag placed before any subcommand (`sheets --badflag`) is whitelisted
|
||||
// away by installUnknownSubcommandGuard, which also leaves args empty —
|
||||
// without this check it would silently fall through to help + exit 0.
|
||||
// Recover the swallowed flag tokens and fail structured so agents (and
|
||||
// the flagDidYouMean contract) still see a real error.
|
||||
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{
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
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,
|
||||
@@ -342,17 +429,66 @@ 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()))
|
||||
// unknownFlagTokens returns the -/-- tokens in rawArgs that cmd does not define.
|
||||
// installUnknownSubcommandGuard whitelists unknown flags on pure groups so a
|
||||
// mistyped subcommand still reaches the suggestion path; the side effect is that
|
||||
// a lone unknown flag (no subcommand) is swallowed, leaving the group to fall
|
||||
// through to help. This recovers those tokens so the caller can fail structured.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -361,10 +497,90 @@ 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(subs)
|
||||
return subs
|
||||
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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -72,6 +73,60 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
@@ -113,11 +168,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 +219,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 +230,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
internal/cmdutil/groups.go
Normal file
18
internal/cmdutil/groups.go
Normal file
@@ -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
|
||||
}
|
||||
57
internal/deprecation/deprecation.go
Normal file
57
internal/deprecation/deprecation.go
Normal file
@@ -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() }
|
||||
58
internal/deprecation/deprecation_test.go
Normal file
58
internal/deprecation/deprecation_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
104
internal/suggest/suggest.go
Normal file
104
internal/suggest/suggest.go
Normal file
@@ -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
|
||||
}
|
||||
74
internal/suggest/suggest_test.go
Normal file
74
internal/suggest/suggest_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,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.
|
||||
@@ -71,6 +72,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 }
|
||||
|
||||
@@ -199,6 +210,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)
|
||||
@@ -748,6 +765,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)
|
||||
@@ -761,6 +801,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
|
||||
@@ -865,6 +930,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 {
|
||||
@@ -894,7 +969,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, 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
|
||||
}
|
||||
|
||||
@@ -917,7 +994,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", fl.Name, 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
|
||||
}
|
||||
}
|
||||
@@ -1002,6 +1081,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":
|
||||
@@ -1033,6 +1116,10 @@ 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 {
|
||||
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -216,3 +216,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -6,7 +6,9 @@ package shortcuts
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/okr"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,6 +16,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 +32,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"
|
||||
@@ -53,35 +57,104 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
|
||||
return slices.Contains(allowed, brand)
|
||||
}
|
||||
|
||||
// allShortcuts aggregates shortcuts from all domain packages.
|
||||
var allShortcuts []common.Shortcut
|
||||
// baseShortcuts aggregates every shortcut except sheets. Sheets are kept out of
|
||||
// the global init path because their registration currently pulls in large
|
||||
// embedded flag metadata; we only mount them when the current invocation is
|
||||
// actually targeting sheets (or when completion/help needs the full tree).
|
||||
var baseShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, im.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, apps.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, calendar.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, doc.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, drive.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, im.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, contact_shortcuts.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, base.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, event.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, mail.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, markdown.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, slides.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, minutes.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, task.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, vc.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, whiteboard.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, wiki.Shortcuts()...)
|
||||
baseShortcuts = append(baseShortcuts, okr.Shortcuts()...)
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
|
||||
//
|
||||
//go:noinline
|
||||
func AllShortcuts() []common.Shortcut {
|
||||
return append([]common.Shortcut(nil), allShortcuts...)
|
||||
return append([]common.Shortcut(nil), allShortcuts(true)...)
|
||||
}
|
||||
|
||||
func allShortcuts(includeSheets bool) []common.Shortcut {
|
||||
out := append([]common.Shortcut(nil), baseShortcuts...)
|
||||
if includeSheets {
|
||||
out = append(out, 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().
|
||||
out = append(out, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func shouldIncludeSheetsShortcuts(_ *cmdutil.Factory) bool {
|
||||
args := os.Args[1:]
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
rootNames := map[string]struct{}{
|
||||
"api": {},
|
||||
"auth": {},
|
||||
"completion": {},
|
||||
"config": {},
|
||||
"doctor": {},
|
||||
"event": {},
|
||||
"help": {},
|
||||
"profile": {},
|
||||
"schema": {},
|
||||
"update": {},
|
||||
}
|
||||
for _, sc := range baseShortcuts {
|
||||
rootNames[sc.Service] = struct{}{}
|
||||
}
|
||||
rootNames["sheets"] = struct{}{}
|
||||
rootNames["__complete"] = struct{}{}
|
||||
rootNames["__completeNoDesc"] = struct{}{}
|
||||
|
||||
for i, arg := range args {
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
if arg == "help" {
|
||||
for _, next := range args[i+1:] {
|
||||
if strings.HasPrefix(next, "-") {
|
||||
continue
|
||||
}
|
||||
return next == "sheets"
|
||||
}
|
||||
return true
|
||||
}
|
||||
if _, ok := rootNames[arg]; !ok {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "sheets", "completion", "__complete", "__completeNoDesc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown argv shape: keep the pre-change conservative behavior and mount
|
||||
// the full tree rather than accidentally hiding a command.
|
||||
return true
|
||||
}
|
||||
|
||||
// RegisterShortcuts registers all +shortcut commands on the program.
|
||||
@@ -100,7 +173,7 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
|
||||
// Group by service
|
||||
byService := make(map[string][]common.Shortcut)
|
||||
for _, s := range allShortcuts {
|
||||
for _, s := range allShortcuts(shouldIncludeSheetsShortcuts(f)) {
|
||||
byService[s.Service] = append(byService[s.Service], s)
|
||||
}
|
||||
|
||||
@@ -146,6 +219,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 +265,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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -46,8 +49,15 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
|
||||
return program
|
||||
}
|
||||
|
||||
func withRegisterTestArgs(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
orig := os.Args
|
||||
os.Args = append([]string{"lark-cli"}, args...)
|
||||
t.Cleanup(func() { os.Args = orig })
|
||||
}
|
||||
|
||||
func TestAllShortcutsScopesNotNil(t *testing.T) {
|
||||
for _, s := range allShortcuts {
|
||||
for _, s := range AllShortcuts() {
|
||||
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
|
||||
if !hasScopes {
|
||||
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", s.Service, s.Command)
|
||||
@@ -107,6 +117,30 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcuts_SkipsSheetsWhenInvocationTargetsOtherService(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
withRegisterTestArgs(t, "auth", "status")
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
if _, _, err := program.Find([]string{"sheets", "+workbook-info"}); err == nil {
|
||||
t.Fatal("unexpected sheets shortcut mounted for non-sheets invocation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcuts_IncludesSheetsWhenInvocationTargetsSheets(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
withRegisterTestArgs(t, "sheets", "+workbook-info")
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
cmd, _, err := program.Find([]string{"sheets", "+workbook-info"})
|
||||
if err != nil {
|
||||
t.Fatalf("find sheets shortcut: %v", err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != "+workbook-info" {
|
||||
t.Fatalf("sheets shortcut not mounted: %#v", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Service-level cobra commands created by RegisterShortcuts must carry
|
||||
// the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain)
|
||||
// and Rule.Allow path-globs can resolve a command's business domain.
|
||||
@@ -471,3 +505,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)
|
||||
}
|
||||
}
|
||||
|
||||
239
shortcuts/sheets/backward/helpers.go
Normal file
239
shortcuts/sheets/backward/helpers.go
Normal file
@@ -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 <sheetId>! 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)
|
||||
}
|
||||
@@ -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{}{
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -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 "<id>!<id>:<id>".
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
71
shortcuts/sheets/backward/shortcuts.go
Normal file
71
shortcuts/sheets/backward/shortcuts.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
898
shortcuts/sheets/batch_op_contract_test.go
Normal file
898
shortcuts/sheets/batch_op_contract_test.go
Normal file
@@ -0,0 +1,898 @@
|
||||
// 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] (<name>): " 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] (<shortcut>): " — 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",
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
342
shortcuts/sheets/batch_op_dispatch.go
Normal file
342
shortcuts/sheets/batch_op_dispatch.go
Normal file
@@ -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
|
||||
}
|
||||
83
shortcuts/sheets/csv_put_range_alias_test.go
Normal file
83
shortcuts/sheets/csv_put_range_alias_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
4542
shortcuts/sheets/data/flag-defs.json
Normal file
4542
shortcuts/sheets/data/flag-defs.json
Normal file
File diff suppressed because it is too large
Load Diff
6254
shortcuts/sheets/data/flag-schemas.json
Normal file
6254
shortcuts/sheets/data/flag-schemas.json
Normal file
File diff suppressed because it is too large
Load Diff
578
shortcuts/sheets/execute_paths_test.go
Normal file
578
shortcuts/sheets/execute_paths_test.go
Normal file
@@ -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 <resolve>, 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
|
||||
}
|
||||
94
shortcuts/sheets/flag_defs.go
Normal file
94
shortcuts/sheets/flag_defs.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"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. We embed it and build each
|
||||
// shortcut's []common.Flag from it 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 data/flag-defs.json; regenerate via the sync script.
|
||||
|
||||
//go:embed data/flag-defs.json
|
||||
var flagDefsJSON []byte
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
var (
|
||||
flagDefsOnce sync.Once
|
||||
flagDefs map[string]commandDef
|
||||
flagDefsErr error
|
||||
)
|
||||
|
||||
func loadFlagDefs() (map[string]commandDef, error) {
|
||||
flagDefsOnce.Do(func() {
|
||||
flagDefs = make(map[string]commandDef)
|
||||
if err := json.Unmarshal(flagDefsJSON, &flagDefs); err != nil {
|
||||
flagDefsErr = fmt.Errorf("flag-defs.json: %w", err)
|
||||
}
|
||||
})
|
||||
return flagDefs, flagDefsErr
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
142
shortcuts/sheets/flag_defs_test.go
Normal file
142
shortcuts/sheets/flag_defs_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
shortcuts/sheets/flag_schema.go
Normal file
124
shortcuts/sheets/flag_schema.go
Normal file
@@ -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 <shortcut>
|
||||
// --print-schema --flag-name <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 <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, "", " ")
|
||||
}
|
||||
}
|
||||
209
shortcuts/sheets/flag_schema_test.go
Normal file
209
shortcuts/sheets/flag_schema_test.go
Normal file
@@ -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
|
||||
}
|
||||
490
shortcuts/sheets/flag_schema_validate.go
Normal file
490
shortcuts/sheets/flag_schema_validate.go
Normal file
@@ -0,0 +1,490 @@
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
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.
|
||||
// - <schema> → extra keys allowed, but each value must validate
|
||||
// against this schema. Used today for pivot's dynamic
|
||||
// map<string, array<string>> 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
|
||||
}
|
||||
589
shortcuts/sheets/flag_schema_validate_test.go
Normal file
589
shortcuts/sheets/flag_schema_validate_test.go
Normal file
@@ -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:<schema>: 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: <schema> form against the real embedded
|
||||
// schema. +pivot-create properties.collapse is declared as a dynamic
|
||||
// map<field-name, array<string>>; 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)
|
||||
}
|
||||
}
|
||||
308
shortcuts/sheets/flag_view.go
Normal file
308
shortcuts/sheets/flag_view.go
Normal file
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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] (<shortcut>) 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", "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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
|
||||
|
||||
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/<token>")
|
||||
}
|
||||
if err := validate.RejectControlChars(token, "url"); err != nil {
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// extractSpreadsheetToken extracts spreadsheet token from URL.
|
||||
// extractSpreadsheetToken pulls the token segment out of a /sheets/<token>
|
||||
// or /spreadsheets/<token> 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
|
||||
// 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 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 <sheetId>! 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
|
||||
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
|
||||
}
|
||||
name := strings.TrimSpace(runtime.Str("sheet-name"))
|
||||
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
|
||||
return "", "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return "", name, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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
|
||||
// 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)
|
||||
}
|
||||
return singleCellRangePattern.MatchString(input) ||
|
||||
cellSpanRangePattern.MatchString(input) ||
|
||||
cellToColRangePattern.MatchString(input) ||
|
||||
colSpanRangePattern.MatchString(input) ||
|
||||
rowSpanRangePattern.MatchString(input)
|
||||
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 nil
|
||||
}
|
||||
|
||||
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
|
||||
// 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 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 {
|
||||
// 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 sheetID + "!" + anchor + ":" + endCell
|
||||
return "<resolve:" + sheetName + ">"
|
||||
}
|
||||
|
||||
func matrixDimensions(values interface{}) (rows, cols int) {
|
||||
rowList, ok := values.([]interface{})
|
||||
if !ok || len(rowList) == 0 {
|
||||
return 1, 1
|
||||
// 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
|
||||
}
|
||||
rows = len(rowList)
|
||||
for _, row := range rowList {
|
||||
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
|
||||
cols = len(cells)
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
|
||||
}
|
||||
if cols == 0 {
|
||||
cols = 1
|
||||
// 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 rows, cols
|
||||
return out, 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)
|
||||
}
|
||||
colIndex := columnNameToIndex(matches[1])
|
||||
if colIndex < 1 {
|
||||
return "", fmt.Errorf("invalid column: %s", matches[1])
|
||||
}
|
||||
rowIndex, err := strconv.Atoi(matches[2])
|
||||
// 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 "", err
|
||||
return nil, err
|
||||
}
|
||||
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
|
||||
if v == nil {
|
||||
return nil, common.FlagErrorf("--%s is required", name)
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object", name)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func columnNameToIndex(name string) int {
|
||||
name = strings.ToUpper(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return 0
|
||||
// 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
|
||||
}
|
||||
index := 0
|
||||
for _, r := range name {
|
||||
if r < 'A' || r > 'Z' {
|
||||
return 0
|
||||
}
|
||||
index = index*26 + int(r-'A'+1)
|
||||
if v == nil {
|
||||
return nil, common.FlagErrorf("--%s is required", name)
|
||||
}
|
||||
return index
|
||||
a, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array", name)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func columnIndexToName(index int) string {
|
||||
if index < 1 {
|
||||
return ""
|
||||
// ─── 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
|
||||
}
|
||||
var out []byte
|
||||
for index > 0 {
|
||||
index--
|
||||
out = append([]byte{byte('A' + index%26)}, out...)
|
||||
index /= 26
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
return string(out)
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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 m, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)")
|
||||
}
|
||||
|
||||
203
shortcuts/sheets/helpers_test.go
Normal file
203
shortcuts/sheets/helpers_test.go
Normal file
@@ -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"
|
||||
)
|
||||
502
shortcuts/sheets/lark_sheet_batch_update.go
Normal file
502
shortcuts/sheets/lark_sheet_batch_update.go
Normal file
@@ -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
|
||||
}
|
||||
495
shortcuts/sheets/lark_sheet_batch_update_test.go
Normal file
495
shortcuts/sheets/lark_sheet_batch_update_test.go
Normal file
@@ -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
|
||||
}
|
||||
1043
shortcuts/sheets/lark_sheet_object_crud.go
Normal file
1043
shortcuts/sheets/lark_sheet_object_crud.go
Normal file
File diff suppressed because it is too large
Load Diff
672
shortcuts/sheets/lark_sheet_object_crud_test.go
Normal file
672
shortcuts/sheets/lark_sheet_object_crud_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
157
shortcuts/sheets/lark_sheet_object_list.go
Normal file
157
shortcuts/sheets/lark_sheet_object_list.go
Normal file
@@ -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 --<id> 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",
|
||||
})
|
||||
111
shortcuts/sheets/lark_sheet_object_list_test.go
Normal file
111
shortcuts/sheets/lark_sheet_object_list_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
665
shortcuts/sheets/lark_sheet_range_operations.go
Normal file
665
shortcuts/sheets/lark_sheet_range_operations.go
Normal file
@@ -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 <px>")
|
||||
}
|
||||
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
|
||||
}
|
||||
360
shortcuts/sheets/lark_sheet_range_operations_test.go
Normal file
360
shortcuts/sheets/lark_sheet_range_operations_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
523
shortcuts/sheets/lark_sheet_read_data.go
Normal file
523
shortcuts/sheets/lark_sheet_read_data.go
Normal file
@@ -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
|
||||
}
|
||||
291
shortcuts/sheets/lark_sheet_read_data_test.go
Normal file
291
shortcuts/sheets/lark_sheet_read_data_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
172
shortcuts/sheets/lark_sheet_search_replace.go
Normal file
172
shortcuts/sheets/lark_sheet_search_replace.go
Normal file
@@ -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
|
||||
}
|
||||
102
shortcuts/sheets/lark_sheet_search_replace_test.go
Normal file
102
shortcuts/sheets/lark_sheet_search_replace_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
679
shortcuts/sheets/lark_sheet_sheet_structure.go
Normal file
679
shortcuts/sheets/lark_sheet_sheet_structure.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
342
shortcuts/sheets/lark_sheet_sheet_structure_test.go
Normal file
342
shortcuts/sheets/lark_sheet_sheet_structure_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
1035
shortcuts/sheets/lark_sheet_workbook.go
Normal file
1035
shortcuts/sheets/lark_sheet_workbook.go
Normal file
File diff suppressed because it is too large
Load Diff
439
shortcuts/sheets/lark_sheet_workbook_test.go
Normal file
439
shortcuts/sheets/lark_sheet_workbook_test.go
Normal file
@@ -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
|
||||
// <resolve> 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: "<resolve>",
|
||||
},
|
||||
{
|
||||
name: "name only → sheet_id placeholder + source_index placeholder",
|
||||
args: []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
|
||||
wantSheetID: "<resolve:汇总>",
|
||||
wantSourceIdx: "<resolve>",
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
824
shortcuts/sheets/lark_sheet_write_cells.go
Normal file
824
shortcuts/sheets/lark_sheet_write_cells.go
Normal file
@@ -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: <strings>}
|
||||
// --source-range -> {type: "listFromRange", range: <A1+sheet prefix>}
|
||||
// --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": "<file_token>",
|
||||
"image_width": "<image_width>",
|
||||
"image_height": "<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_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.",
|
||||
},
|
||||
}
|
||||
542
shortcuts/sheets/lark_sheet_write_cells_test.go
Normal file
542
shortcuts/sheets/lark_sheet_write_cells_test.go
Normal file
@@ -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"] != "<file_token>" {
|
||||
t.Errorf("image_token = %v, want <file_token>", item["image_token"])
|
||||
}
|
||||
if item["text"] != "" {
|
||||
t.Errorf("text = %v, want empty string", item["text"])
|
||||
}
|
||||
if item["image_width"] != "<image_width>" {
|
||||
t.Errorf("image_width = %v, want <image_width>", item["image_width"])
|
||||
}
|
||||
if item["image_height"] != "<image_height>" {
|
||||
t.Errorf("image_height = %v, want <image_height>", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
shortcuts/sheets/sheet_ai_api.go
Normal file
119
shortcuts/sheets/sheet_ai_api.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -5,67 +5,100 @@ 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 <name>` locally.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
all := shortcutList()
|
||||
withSchema := commandsWithFlagSchema()
|
||||
for i := range all {
|
||||
if _, ok := withSchema[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,
|
||||
SheetDelete,
|
||||
SheetRename,
|
||||
SheetMove,
|
||||
SheetCopy,
|
||||
SheetHide,
|
||||
SheetUnhide,
|
||||
SheetSetTabColor,
|
||||
WorkbookCreate,
|
||||
WorkbookExport,
|
||||
|
||||
// lark_sheet_sheet_structure
|
||||
SheetInfo,
|
||||
SheetExport,
|
||||
DimInsert,
|
||||
DimDelete,
|
||||
DimHide,
|
||||
DimUnhide,
|
||||
DimFreeze,
|
||||
DimGroup,
|
||||
DimUngroup,
|
||||
DimMove,
|
||||
|
||||
// Sheet management
|
||||
SheetCreateSheet,
|
||||
SheetCopySheet,
|
||||
SheetDeleteSheet,
|
||||
SheetUpdateSheet,
|
||||
// lark_sheet_read_data
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
DropdownGet,
|
||||
|
||||
// Cell data
|
||||
SheetRead,
|
||||
SheetWrite,
|
||||
SheetAppend,
|
||||
SheetFind,
|
||||
SheetReplace,
|
||||
// lark_sheet_search_replace
|
||||
CellsSearch,
|
||||
CellsReplace,
|
||||
|
||||
// Cell style and merge
|
||||
SheetSetStyle,
|
||||
SheetBatchSetStyle,
|
||||
SheetMergeCells,
|
||||
SheetUnmergeCells,
|
||||
// lark_sheet_write_cells
|
||||
CellsSet,
|
||||
CellsSetStyle,
|
||||
CellsSetImage,
|
||||
CsvPut,
|
||||
DropdownSet,
|
||||
|
||||
// Cell images
|
||||
SheetWriteImage,
|
||||
// lark_sheet_range_operations
|
||||
CellsClear,
|
||||
CellsMerge,
|
||||
CellsUnmerge,
|
||||
RowsResize,
|
||||
ColsResize,
|
||||
RangeMove,
|
||||
RangeCopy,
|
||||
RangeFill,
|
||||
RangeSort,
|
||||
|
||||
// Row/column management
|
||||
SheetAddDimension,
|
||||
SheetInsertDimension,
|
||||
SheetUpdateDimension,
|
||||
SheetMoveDimension,
|
||||
SheetDeleteDimension,
|
||||
// Object list (one read shortcut per object skill)
|
||||
ChartList,
|
||||
PivotList,
|
||||
CondFormatList,
|
||||
FilterList,
|
||||
FilterViewList,
|
||||
SparklineList,
|
||||
FloatImageList,
|
||||
|
||||
// Filter views
|
||||
SheetCreateFilterView,
|
||||
SheetUpdateFilterView,
|
||||
SheetListFilterViews,
|
||||
SheetGetFilterView,
|
||||
SheetDeleteFilterView,
|
||||
SheetCreateFilterViewCondition,
|
||||
SheetUpdateFilterViewCondition,
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
// 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,
|
||||
|
||||
// Dropdown
|
||||
SheetSetDropdown,
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
|
||||
// Float images
|
||||
SheetMediaUpload,
|
||||
SheetCreateFloatImage,
|
||||
SheetUpdateFloatImage,
|
||||
SheetGetFloatImage,
|
||||
SheetListFloatImages,
|
||||
SheetDeleteFloatImage,
|
||||
// lark_sheet_batch_update
|
||||
BatchUpdate,
|
||||
CellsBatchSetStyle,
|
||||
CellsBatchClear,
|
||||
DropdownUpdate,
|
||||
DropdownDelete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),其中包含认证、权限处理。**
|
||||
|
||||
## 快速决策
|
||||
- 已知 spreadsheet URL / token 后,再进入 `sheets +info`、`sheets +read`、`sheets +find` 等对象内部操作。
|
||||
## 术语约定
|
||||
|
||||
## 核心概念
|
||||
下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念:
|
||||
|
||||
### 文档类型与 Token
|
||||
| 标准用语 | 同义 / 口语(均指同一对象) | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 工作表(sheet) | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 |
|
||||
| 电子表格(spreadsheet) | 工作簿、表格 | 顶层容器;由 `--url` 或 `--spreadsheet-token` 定位 |
|
||||
| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image` 的 `--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id |
|
||||
|
||||
飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`。
|
||||
每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼):
|
||||
|
||||
### 文档 URL 格式与 Token 处理
|
||||
| 对象 | 主键 flag | 对象 | 主键 flag |
|
||||
| --- | --- | --- | --- |
|
||||
| 工作表 sheet | `--sheet-id` | 条件格式规则 | `--rule-id` |
|
||||
| 图表 chart | `--chart-id` | 筛选视图 | `--view-id` |
|
||||
| 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` |
|
||||
| 浮动图片 | `--float-image-id` | | |
|
||||
|
||||
| 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 使用 |
|
||||
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 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 / 本地脚本拼一张假透视表 |
|
||||
|
||||
#### 处理流程
|
||||
> ⚠️ **定位 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。
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
```
|
||||
## References
|
||||
|
||||
2. **从返回结果中提取关键信息**
|
||||
- `node.obj_type`:文档类型(docx/doc/sheet/bitable/slides/file/mindnote)
|
||||
- `node.obj_token`:**真实的文档 token**(用于后续操作)
|
||||
- `node.title`:文档标题
|
||||
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。
|
||||
|
||||
3. **根据 `obj_type` 使用对应的 API**
|
||||
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut)
|
||||
|
||||
| obj_type | 说明 | 使用的 API |
|
||||
|----------|------|-----------|
|
||||
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
|
||||
| `doc` | 旧版云文档 | `drive file.comments.*` |
|
||||
| `sheet` | 电子表格 | `sheets.*` |
|
||||
| `bitable` | 多维表格 | `bitable.*` |
|
||||
| `slides` | 幻灯片 | `drive.*` |
|
||||
| `file` | 文件 | `drive.*` |
|
||||
| `mindnote` | 思维导图 | `drive.*` |
|
||||
| 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 文件执行。 |
|
||||
|
||||
#### 查询示例
|
||||
### 按对象的工具参考(含 shortcut)
|
||||
|
||||
| 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。仅针对飞书表格。 |
|
||||
|
||||
## 公共 flag 速查
|
||||
|
||||
各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如:
|
||||
|
||||
- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」),加 `--dry-run`
|
||||
- `_公共:URL/token(无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token,常见于 `+batch-update` 等不强制 sheet 定位的 shortcut
|
||||
|
||||
徽章里只列名字。type / 必填 / 描述都在本段统一声明:
|
||||
|
||||
### 公共 flag(定位资源)
|
||||
|
||||
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"):
|
||||
|
||||
1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
|
||||
- **`--url` 只解析 `/sheets/` 与 `/spreadsheets/` 两种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
|
||||
- ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token "<wiki 链接或 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
|
||||
# 查询 wiki 节点
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
|
||||
# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给)
|
||||
# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填)
|
||||
# 例:lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
|
||||
# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。
|
||||
```
|
||||
|
||||
返回结果示例:
|
||||
```json
|
||||
{
|
||||
"node": {
|
||||
"obj_type": "docx",
|
||||
"obj_token": "xxxx",
|
||||
"title": "标题",
|
||||
"node_type": "origin",
|
||||
"space_id": "12345678910"
|
||||
}
|
||||
}
|
||||
```
|
||||
### 系统 flag
|
||||
|
||||
### 资源关系
|
||||
| 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 <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`)。 |
|
||||
|
||||
```
|
||||
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)
|
||||
**Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets <shortcut> --print-schema --flag-name <name>` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。
|
||||
|
||||
Drive Folder (云空间/云盘/云存储文件夹)
|
||||
└── File (文件/文档)
|
||||
└── file_token (直接使用)
|
||||
```
|
||||
### flag 内容类型与输出约定(术语速记)
|
||||
|
||||
**操作流程(重要):**
|
||||
- flag 表里 JSON 类入参标三类:**复合 JSON** = 深层嵌套对象(用 `--print-schema` 取完整结构);**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema);**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)。
|
||||
- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get` 的 `annotated_csv`);写操作不会自动回读,如需校验请自行调用对应的 `+*-list` / `+*-get` / `+cells-get`。
|
||||
|
||||
1. **create** — 创建筛选
|
||||
- 用于首次创建筛选
|
||||
- ⚠️ range 必须覆盖所有需要筛选的列(如 B1:E200)
|
||||
- 如果已有筛选存在,再用 create 会覆盖整个筛选
|
||||
## 复合 JSON / 大入参:优先 stdin
|
||||
|
||||
2. **update** — 更新筛选
|
||||
- 用于在已有筛选上添加/更新指定列的条件
|
||||
- 只需指定 col 和 condition,不需要 range
|
||||
flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。
|
||||
|
||||
3. **delete** — 删除筛选
|
||||
|
||||
4. **get** — 获取筛选状态
|
||||
|
||||
**多列筛选示例:**
|
||||
|
||||
创建媒体名称(B列)和情感分析(E列)的双重筛选:
|
||||
推荐写法:payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去:
|
||||
|
||||
```bash
|
||||
# 1. 删除现有筛选(如有)
|
||||
lark-cli sheets spreadsheet.sheet.filters delete \
|
||||
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}'
|
||||
|
||||
# 2. 创建第一个筛选,range 覆盖所有要筛选的列
|
||||
lark-cli sheets spreadsheet.sheet.filters create \
|
||||
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}' \
|
||||
--data '{"col":"B","condition":{"expected":["xx"],"filter_type":"multiValue"},"range":"<sheet_id>!B1:E200"}'
|
||||
|
||||
# 3. 添加第二个筛选条件
|
||||
lark-cli sheets spreadsheet.sheet.filters update \
|
||||
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}' \
|
||||
--data '{"col":"E","condition":{"expected":["xx"],"filter_type":"multiValue"}}'
|
||||
# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录)
|
||||
lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE"
|
||||
```
|
||||
|
||||
**常见错误:**
|
||||
- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create
|
||||
- `Excess Limit`:update 时重复添加同一列条件
|
||||
|
||||
### 单元格数据类型
|
||||
|
||||
接受二维数组的 shortcut(`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。
|
||||
|
||||
| 类型 | 写入格式 | 示例 |
|
||||
|------|---------|------|
|
||||
| 字符串 | `"文本"` | `"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"]}` |
|
||||
|
||||
**写入公式示例**:
|
||||
|
||||
```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)"]]'
|
||||
```
|
||||
|
||||
> **公式语法参考**:涉及 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 +<verb> [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 |
|
||||
|
||||
### Cell Data
|
||||
|
||||
对应参考文档:[cell-data](references/lark-sheets-cell-data.md)
|
||||
|
||||
| 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 |
|
||||
|
||||
### Cell Style And Merge
|
||||
|
||||
对应参考文档:[cell-style-and-merge](references/lark-sheets-cell-style-and-merge.md)
|
||||
|
||||
| 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 |
|
||||
|
||||
### 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 "<float_image_token>" --output ./image.png`。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema sheets.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli sheets <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 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(`--<flag> - < 文件`)。
|
||||
|
||||
191
skills/lark-sheets/references/lark-sheets-batch-update.md
Normal file
191
skills/lark-sheets/references/lark-sheets-batch-update.md
Normal file
@@ -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 <flag>;不要手填 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` 的语义)。
|
||||
@@ -1,197 +0,0 @@
|
||||
# Sheets Cell Data
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总单元格数据操作:
|
||||
|
||||
- `+read`
|
||||
- `+write`
|
||||
- `+append`
|
||||
- `+find`
|
||||
- `+replace`
|
||||
|
||||
<a id="read"></a>
|
||||
## `+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 "<sheetId>!A1:H20"
|
||||
|
||||
lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "C2"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 否 | `<sheetId>!A1:D10`、`A1:D10` / `C2` 或 `<sheetId>` |
|
||||
| `--sheet-id` | 否 | 工作表 ID |
|
||||
| `--value-render-option` | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `range`
|
||||
- `values`
|
||||
- `truncated`
|
||||
- `total_rows`
|
||||
|
||||
<a id="write"></a>
|
||||
## `+write`
|
||||
|
||||
对应命令:`lark-cli sheets +write`
|
||||
|
||||
用于覆盖写入一个矩形区域。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +write --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:B2" \
|
||||
--values '[["name","age"],["alice",18]]'
|
||||
|
||||
lark-cli sheets +write --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "C2" \
|
||||
--values '[["hello"]]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 否 | 写入范围;可用相对范围或 `<sheetId>` |
|
||||
| `--sheet-id` | 否 | 工作表 ID |
|
||||
| `--values` | 是 | 二维数组 JSON |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `updated_range`
|
||||
- `updated_rows`
|
||||
- `updated_columns`
|
||||
- `updated_cells`
|
||||
- `revision`
|
||||
|
||||
<a id="append"></a>
|
||||
## `+append`
|
||||
|
||||
对应命令:`lark-cli sheets +append`
|
||||
|
||||
用于向工作表末尾追加行。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1" \
|
||||
--values '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 否 | 追加范围:支持 `<sheetId>`、完整范围、相对范围 |
|
||||
| `--sheet-id` | 否 | 工作表 ID |
|
||||
| `--values` | 是 | 二维数组 JSON |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `table_range`
|
||||
- `updated_range`
|
||||
- `updated_rows`
|
||||
- `updated_columns`
|
||||
- `updated_cells`
|
||||
- `revision`
|
||||
|
||||
<a id="find"></a>
|
||||
## `+find`
|
||||
|
||||
对应命令:`lark-cli sheets +find`
|
||||
|
||||
只在一个已知 spreadsheet 内查找单元格内容,不是云空间(云盘/云存储)搜索。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +find --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --find "张三" --range "A1:H200"
|
||||
|
||||
lark-cli sheets +find --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --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`
|
||||
|
||||
<a id="replace"></a>
|
||||
## `+replace`
|
||||
|
||||
对应命令:`lark-cli sheets +replace`
|
||||
|
||||
在指定范围内查找并替换单元格内容。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --find "hello" --replacement "world"
|
||||
|
||||
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --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) — 公式写入规则
|
||||
@@ -1,59 +0,0 @@
|
||||
# Sheets Cell Images
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总单元格图片写入能力:
|
||||
|
||||
- `+write-image`
|
||||
|
||||
<a id="write-image"></a>
|
||||
## `+write-image`
|
||||
|
||||
对应命令:`lark-cli sheets +write-image`
|
||||
|
||||
特性:
|
||||
|
||||
- 将本地图片文件写入到指定单元格
|
||||
- 支持格式:PNG、JPEG、JPG、GIF、BMP、JFIF、EXIF、TIFF、BPG、HEIC
|
||||
- `--range` 必须表示单个单元格,如 `A1` 或 `<sheetId>!B2:B2`
|
||||
- `--name` 默认取 `--image` 的文件名
|
||||
|
||||
```bash
|
||||
# 写入图片到指定单元格
|
||||
lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!B2:B2" \
|
||||
--image "./logo.png"
|
||||
|
||||
# 使用 URL + sheet-id,指定单个单元格
|
||||
lark-cli sheets +write-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "C3" \
|
||||
--image "./chart.jpg"
|
||||
|
||||
# 自定义图片名称
|
||||
lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:A1" \
|
||||
--image "./output.png" --name "revenue_chart.png"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 目标单元格:`<sheetId>!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) — 管理浮动图片
|
||||
@@ -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`
|
||||
|
||||
<a id="set-style"></a>
|
||||
## `+set-style`
|
||||
|
||||
对应命令:`lark-cli sheets +set-style`
|
||||
|
||||
对指定范围设置字体、颜色、对齐、边框等样式。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:C3" \
|
||||
--style '{"font":{"bold":true},"backColor":"#ff0000"}'
|
||||
|
||||
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!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)
|
||||
|
||||
<a id="batch-set-style"></a>
|
||||
## `+batch-set-style`
|
||||
|
||||
对应命令:`lark-cli sheets +batch-set-style`
|
||||
|
||||
对多个范围批量设置不同样式。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--data '[{"ranges":["<sheetId>!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["<sheetId>!D1:F3"],"style":{"foreColor":"#ff0000"}}]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--data` | 是 | JSON 数组,每项包含 `ranges` 和 `style` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `totalUpdatedRows`
|
||||
- `totalUpdatedColumns`
|
||||
- `totalUpdatedCells`
|
||||
- `revision`
|
||||
- `responses[]`
|
||||
|
||||
<a id="merge-cells"></a>
|
||||
## `+merge-cells`
|
||||
|
||||
对应命令:`lark-cli sheets +merge-cells`
|
||||
|
||||
支持三种模式:
|
||||
|
||||
- `MERGE_ALL`
|
||||
- `MERGE_ROWS`
|
||||
- `MERGE_COLUMNS`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!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`
|
||||
|
||||
<a id="unmerge-cells"></a>
|
||||
## `+unmerge-cells`
|
||||
|
||||
对应命令:`lark-cli sheets +unmerge-cells`
|
||||
|
||||
用于拆分合并单元格。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!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) — 写入单元格图片
|
||||
319
skills/lark-sheets/references/lark-sheets-chart.md
Normal file
319
skills/lark-sheets/references/lark-sheets-chart.md
Normal file
@@ -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 <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`。
|
||||
179
skills/lark-sheets/references/lark-sheets-conditional-format.md
Normal file
179
skills/lark-sheets/references/lark-sheets-conditional-format.md
Normal file
@@ -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<string>) — 应用条件格式的 A1 范围列表 — ⚠️ 已拎为独立 flag `--ranges`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
|
||||
- `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, fore_color?: string, text_decoration?: enum, font?: enum }
|
||||
- `attrs` (array<oneOf>?) — 规则参数列表
|
||||
- `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 <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 <id>` 比对规则 / 范围 / 样式。
|
||||
102
skills/lark-sheets/references/lark-sheets-core-operations.md
Normal file
102
skills/lark-sheets/references/lark-sheets-core-operations.md
Normal file
@@ -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` 即可整列处理,远比逐行修改高效。
|
||||
@@ -1,133 +0,0 @@
|
||||
# Sheets Dropdown
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总下拉列表配置:
|
||||
|
||||
- `+set-dropdown`
|
||||
- `+update-dropdown`
|
||||
- `+get-dropdown`
|
||||
- `+delete-dropdown`
|
||||
|
||||
> **关键规则:** 使用 `multipleValue` 写入前,必须先设置下拉列表;否则值会被当成纯文本。
|
||||
|
||||
<a id="set-dropdown"></a>
|
||||
## `+set-dropdown`
|
||||
|
||||
对应命令:`lark-cli sheets +set-dropdown`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`) |
|
||||
| `--condition-values` | 是 | 下拉选项 JSON 数组 |
|
||||
| `--multiple` | 否 | 是否多选 |
|
||||
| `--highlight` | 否 | 是否着色 |
|
||||
| `--colors` | 否 | 颜色 JSON 数组 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:`code`、`msg`
|
||||
|
||||
<a id="update-dropdown"></a>
|
||||
## `+update-dropdown`
|
||||
|
||||
对应命令:`lark-cli sheets +update-dropdown`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" \
|
||||
--ranges '["<sheetId>!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`
|
||||
|
||||
<a id="get-dropdown"></a>
|
||||
## `+get-dropdown`
|
||||
|
||||
对应命令:`lark-cli sheets +get-dropdown`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!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`
|
||||
|
||||
<a id="delete-dropdown"></a>
|
||||
## `+delete-dropdown`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-dropdown`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--ranges '["<sheetId>!A2:A100", "<sheetId>!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 "<url>" \
|
||||
--range "<sheetId>!J2:J100" --condition-values '["选项1","选项2"]' --multiple
|
||||
|
||||
# 2. 再写入 multipleValue
|
||||
lark-cli sheets +write --url "<url>" --sheet-id "<sheetId>" --range "J2" \
|
||||
--values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]'
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据
|
||||
126
skills/lark-sheets/references/lark-sheets-filter-view.md
Normal file
126
skills/lark-sheets/references/lark-sheets-filter-view.md
Normal file
@@ -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<object>?) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array<oneOf>, filtered_rows?: array<number> }
|
||||
- `filtered_columns` (array<string>?) — 可选
|
||||
|
||||
## 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 输出里生成为 `<resolve:Sheet1>` 占位符。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-view-list --view-id <new>` 比对当前 range + rules。
|
||||
@@ -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`
|
||||
|
||||
<a id="create-filter-view"></a>
|
||||
## `+create-filter-view`
|
||||
|
||||
对应命令:`lark-cli sheets +create-filter-view`
|
||||
|
||||
在工作表中创建筛选视图,每个工作表最多 150 个。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14"
|
||||
|
||||
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "<sheetId>!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`
|
||||
|
||||
<a id="update-filter-view"></a>
|
||||
## `+update-filter-view`
|
||||
|
||||
对应命令:`lark-cli sheets +update-filter-view`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --range "<sheetId>!A1:J20"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--range` | 否 | 新范围 |
|
||||
| `--filter-view-name` | 否 | 新显示名称 |
|
||||
|
||||
<a id="list-filter-views"></a>
|
||||
## `+list-filter-views`
|
||||
|
||||
对应命令:`lark-cli sheets +list-filter-views`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>"
|
||||
```
|
||||
|
||||
输出:`items[]`(`filter_view_id`、`filter_view_name`、`range`)
|
||||
|
||||
<a id="get-filter-view"></a>
|
||||
## `+get-filter-view`
|
||||
|
||||
对应命令:`lark-cli sheets +get-filter-view`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
输出:`filter_view`
|
||||
|
||||
<a id="delete-filter-view"></a>
|
||||
## `+delete-filter-view`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-filter-view`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
|
||||
<a id="create-filter-view-condition"></a>
|
||||
## `+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 "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--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 "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--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`
|
||||
|
||||
<a id="update-filter-view-condition"></a>
|
||||
## `+update-filter-view-condition`
|
||||
|
||||
对应命令:`lark-cli sheets +update-filter-view-condition`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" \
|
||||
--filter-type "number" --compare-type "between" --expected '["2","10"]'
|
||||
```
|
||||
|
||||
参数与创建条件相同,但 `filter-type` / `compare-type` / `expected` 可按需部分更新。
|
||||
|
||||
<a id="list-filter-view-conditions"></a>
|
||||
## `+list-filter-view-conditions`
|
||||
|
||||
对应命令:`lark-cli sheets +list-filter-view-conditions`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
输出:`items[]`
|
||||
|
||||
<a id="get-filter-view-condition"></a>
|
||||
## `+get-filter-view-condition`
|
||||
|
||||
对应命令:`lark-cli sheets +get-filter-view-condition`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
|
||||
```
|
||||
|
||||
输出:`condition`
|
||||
|
||||
<a id="delete-filter-view-condition"></a>
|
||||
## `+delete-filter-view-condition`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-filter-view-condition`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [dropdown](lark-sheets-dropdown.md) — 需要下拉值配合筛选时
|
||||
- [cell-data](lark-sheets-cell-data.md#find) — 只查数据时用 `+find`
|
||||
119
skills/lark-sheets/references/lark-sheets-filter.md
Normal file
119
skills/lark-sheets/references/lark-sheets-filter.md
Normal file
@@ -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<object>) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array<oneOf>, filtered_rows?: array<number> }
|
||||
- `filtered_columns` (array<string>?) — 可选
|
||||
|
||||
## 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` 查看当前筛选条件 + 已过滤行数。
|
||||
158
skills/lark-sheets/references/lark-sheets-float-image.md
Normal file
158
skills/lark-sheets/references/lark-sheets-float-image.md
Normal file
@@ -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 <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 <id>` 比对新位置 / 尺寸。
|
||||
@@ -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`
|
||||
|
||||
<a id="media-upload"></a>
|
||||
## `+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`
|
||||
|
||||
<a id="create-float-image"></a>
|
||||
## `+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 "<sheetId>" --float-image-token "boxcnXXXX" \
|
||||
--range "<sheetId>!A1:A1" --width 200 --height 150
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
- `--float-image-token` 必须来自 `+media-upload`
|
||||
- `--range` 必须锚定单个单元格
|
||||
- `width` / `height` 必须 `>=20`
|
||||
- `offset-x` / `offset-y` 必须 `>=0`
|
||||
|
||||
输出:`float_image`
|
||||
|
||||
<a id="update-float-image"></a>
|
||||
## `+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 "<sheetId>" --float-image-id "fi12345678" \
|
||||
--width 400 --height 300 --offset-y 20
|
||||
```
|
||||
|
||||
至少需要传一个更新字段:`--range` / `--width` / `--height` / `--offset-x` / `--offset-y`
|
||||
|
||||
输出:更新后的 `float_image`
|
||||
|
||||
<a id="get-float-image"></a>
|
||||
## `+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 "<sheetId>" --float-image-id "fi12345678"
|
||||
```
|
||||
|
||||
输出:`float_image`
|
||||
|
||||
<a id="list-float-images"></a>
|
||||
## `+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 "<sheetId>"
|
||||
```
|
||||
|
||||
输出:`items[]`
|
||||
|
||||
<a id="delete-float-image"></a>
|
||||
## `+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 "<sheetId>" --float-image-id "fi12345678"
|
||||
```
|
||||
|
||||
输出:`code`、`msg`
|
||||
|
||||
## 读取图片内容
|
||||
|
||||
上述读接口只返回元数据,不返回图片字节。要读取图片内容,用 `float_image_token` 调:
|
||||
|
||||
```bash
|
||||
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [cell-images](lark-sheets-cell-images.md) — 写入到单元格的图片
|
||||
- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id`
|
||||
267
skills/lark-sheets/references/lark-sheets-formula-translation.md
Normal file
267
skills/lark-sheets/references/lark-sheets-formula-translation.md
Normal file
@@ -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))`
|
||||
@@ -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*` | 飞书无等价函数 |
|
||||
166
skills/lark-sheets/references/lark-sheets-pivot-table.md
Normal file
166
skills/lark-sheets/references/lark-sheets-pivot-table.md
Normal file
@@ -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 <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<object>?) — 纵向分组字段(行字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 }
|
||||
- `columns` (array<object>?) — 横向分组字段(列字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 }
|
||||
- `filters` (array<object>?) — 筛选区域字段(页字段) each: { field: string, display_name?: string, filter?: object, condition_filter?: object, group?: object }
|
||||
- `values` (array<object>?) — 要汇总的字段(至少需要 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<object>?) — 计算字段列表 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 <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 <id>` 并用 `+csv-get` 抽样读透视产物核对输出尺寸 + 总计行位置。
|
||||
|
||||
> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`snapshot.data.refs` 必须排除这些行(见 `lark-sheets-chart` 的「⚠️ chart 数据源引用 pivot 时必须排除总计行」段)。
|
||||
263
skills/lark-sheets/references/lark-sheets-range-operations.md
Normal file
263
skills/lark-sheets/references/lark-sheets-range-operations.md
Normal file
@@ -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":<bool>}, ...]` |
|
||||
| `--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 <影响范围>` 抽样比对。
|
||||
171
skills/lark-sheets/references/lark-sheets-read-data.md
Normal file
171
skills/lark-sheets/references/lark-sheets-read-data.md
Normal file
@@ -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 输出里生成为 `<resolve:销售明细>` 占位符,不实际解析。
|
||||
- `Execute` 阶段才进行 sheet-name → sheet-id 解析与 API 调用。
|
||||
@@ -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`
|
||||
|
||||
<a id="add-dimension"></a>
|
||||
## `+add-dimension`
|
||||
|
||||
对应命令:`lark-cli sheets +add-dimension`
|
||||
|
||||
在工作表末尾追加空行或空列,不影响已有数据。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --dimension ROWS --length 10
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--dimension` | 是 | `ROWS` 或 `COLUMNS` |
|
||||
| `--length` | 是 | 追加数量(1-5000) |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:`addCount`、`majorDimension`
|
||||
|
||||
<a id="insert-dimension"></a>
|
||||
## `+insert-dimension`
|
||||
|
||||
对应命令:`lark-cli sheets +insert-dimension`
|
||||
|
||||
在指定位置插入空行或空列,已有数据向下或向右移动。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --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` 为空对象 `{}`
|
||||
|
||||
<a id="update-dimension"></a>
|
||||
## `+update-dimension`
|
||||
|
||||
对应命令:`lark-cli sheets +update-dimension`
|
||||
|
||||
更新指定范围行/列的显隐状态和行高/列宽。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --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` 为空对象 `{}`
|
||||
|
||||
<a id="move-dimension"></a>
|
||||
## `+move-dimension`
|
||||
|
||||
对应命令:`lark-cli sheets +move-dimension`
|
||||
|
||||
将指定范围的行/列移动到目标位置。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --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` 为空对象 `{}`
|
||||
|
||||
<a id="delete-dimension"></a>
|
||||
## `+delete-dimension`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-dimension`
|
||||
|
||||
删除指定范围的行或列。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --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) — 调整样式或合并单元格
|
||||
111
skills/lark-sheets/references/lark-sheets-search-replace.md
Normal file
111
skills/lark-sheets/references/lark-sheets-search-replace.md
Normal file
@@ -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` 复查旧值是否已不再命中。
|
||||
@@ -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` 封装“更新工作表属性”接口。
|
||||
|
||||
<a id="create-sheet"></a>
|
||||
## `+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`
|
||||
|
||||
<a id="copy-sheet"></a>
|
||||
## `+copy-sheet`
|
||||
|
||||
对应命令:`lark-cli sheets +copy-sheet`
|
||||
|
||||
```bash
|
||||
# 按默认位置复制
|
||||
lark-cli sheets +copy-sheet --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>"
|
||||
|
||||
# 指定副本名称和位置
|
||||
lark-cli sheets +copy-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --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`
|
||||
|
||||
<a id="delete-sheet"></a>
|
||||
## `+delete-sheet`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-sheet`
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**高风险删除操作**。CLI 会要求显式确认;可以先用 `--dry-run` 预览。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-sheet --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 要删除的工作表 ID |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `deleted`
|
||||
- `spreadsheet_token`
|
||||
- `sheet_id`
|
||||
|
||||
<a id="update-sheet"></a>
|
||||
## `+update-sheet`
|
||||
|
||||
对应命令:`lark-cli sheets +update-sheet`
|
||||
|
||||
用于更新工作表标题、位置、隐藏状态、冻结行列和保护设置。
|
||||
|
||||
```bash
|
||||
# 改名 + 调整冻结
|
||||
lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --title "汇总表" --frozen-row-count 2 --frozen-col-count 1
|
||||
|
||||
# 隐藏工作表
|
||||
lark-cli sheets +update-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --hidden=true
|
||||
|
||||
# 开启保护并授权额外编辑人
|
||||
lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --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) — 需要改行列结构时用这组命令
|
||||
212
skills/lark-sheets/references/lark-sheets-sheet-structure.md
Normal file
212
skills/lark-sheets/references/lark-sheets-sheet-structure.md
Normal file
@@ -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` 查看受影响的范围。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user