mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
32 Commits
feat/eval_
...
feat-undo-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f381aa439 | ||
|
|
251635ec1e | ||
|
|
4936a983bf | ||
|
|
886cca6032 | ||
|
|
b64018a672 | ||
|
|
1996b67451 | ||
|
|
c1ee8613e4 | ||
|
|
41e6acba11 | ||
|
|
a042942f7e | ||
|
|
66c16758ec | ||
|
|
b42db647ff | ||
|
|
1cafb94a62 | ||
|
|
0b33daa136 | ||
|
|
5a61b97ac3 | ||
|
|
e01f2dfdd5 | ||
|
|
45f807459e | ||
|
|
8906e87fb1 | ||
|
|
0ff7f0407e | ||
|
|
6e067f2180 | ||
|
|
c000dc3a44 | ||
|
|
256df8c0fb | ||
|
|
7a0dbe057b | ||
|
|
8ce38793a7 | ||
|
|
54e646edc9 | ||
|
|
b07a6003f9 | ||
|
|
03a589978f | ||
|
|
b3fcf55611 | ||
|
|
2f35ce3724 | ||
|
|
7e7f716a82 | ||
|
|
1670a794f6 | ||
|
|
33de28fd1a | ||
|
|
85c7280d8b |
@@ -57,6 +57,14 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/gen build-time generators (standalone `package main` run via
|
||||
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||
# impossible here: a structured error return needs os.Exit (also banned),
|
||||
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||
- path: shortcuts/.*/internal/gen/
|
||||
linters:
|
||||
- forbidigo
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -2,6 +2,26 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.47] - 2026-06-03
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
|
||||
- **base**: Add base block shortcuts (#1044)
|
||||
- **im**: Complete card message format (#1198)
|
||||
- **im**: Improve markdown guidance for messages (#1237)
|
||||
- **vc**: Forward invite call-id on meeting join (#1243)
|
||||
- **drive**: Emit typed error envelopes across the drive domain (#1205)
|
||||
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
|
||||
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
|
||||
- **wiki**: Support `appid` member type (#1235)
|
||||
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
|
||||
- **config**: Validate credentials after `config init` (#1151)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **skills**: Recover empty fallback for skills to update (#1233)
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
@@ -989,6 +1009,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
377
cmd/root.go
377
cmd/root.go
@@ -18,14 +18,17 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
@@ -69,7 +72,15 @@ COMMUNITY:
|
||||
More help: lark-cli <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)
|
||||
@@ -133,29 +144,49 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if 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.
|
||||
@@ -260,6 +291,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||
// runs before cobra's required-flag validation — but a missing required flag
|
||||
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||
// the notice. When a deprecation is pending, route through the structured
|
||||
// envelope so the migration hint still reaches the caller; all other errors
|
||||
// keep the existing plain output.
|
||||
if deprecation.GetPending() != nil {
|
||||
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||
}, string(f.ResolvedIdentity))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -301,6 +345,12 @@ func asExitError(err error) *output.ExitError {
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
||||
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
||||
// consumes no flags itself, so unknown flags belong to the (missing)
|
||||
// subcommand; whitelisting them here prevents cobra from erroring on the
|
||||
// flag first and printing usage instead of our structured suggestion.
|
||||
cmd.FParseErrWhitelist.UnknownFlags = true
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
@@ -320,14 +370,89 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||
// like the global --profile, legitimately prints help. But a flag that
|
||||
// belongs to a (missing) subcommand is a user error: the guard's
|
||||
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
||||
// the checks below they would silently fall through to help + exit 0 —
|
||||
// letting an agent mistake a malformed call (`im --format json`,
|
||||
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
||||
// raw invocation and fail structured instead.
|
||||
flags := flagTokensInArgs(rawInvocationArgs)
|
||||
if len(flags) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
// Keep the same detail keys as flagDidYouMean's unknown_flag
|
||||
// so a consumer keyed on Type can read a stable shape. The
|
||||
// subcommand isn't resolved here, so suggestions/valid_flags
|
||||
// have no meaningful universe to draw from — emit empty
|
||||
// rather than the group's own (misleading) flags. unknown is
|
||||
// the back-compat singular field; unknown_flags carries the
|
||||
// full list when more than one flag was supplied.
|
||||
"unknown": strings.Join(unknown, ", "),
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": []string{},
|
||||
"valid_flags": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
// The remaining flags are all defined somewhere in the tree. Those valid
|
||||
// on the group itself or inherited (e.g. the global --profile) do not
|
||||
// require a subcommand, so a bare group carrying only those still prints
|
||||
// help. Anything left belongs to a subcommand that was omitted
|
||||
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
||||
// real, the subcommand is what's missing.
|
||||
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_subcommand",
|
||||
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
|
||||
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
"command_path": cmd.CommandPath(),
|
||||
"flags": misplaced,
|
||||
"suggestions": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
@@ -335,17 +460,114 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
},
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
||||
// defined is not considered (see unknownFlagTokens for that). A pure group
|
||||
// with any flag token but no subcommand is a user error — a pure group
|
||||
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
||||
// the caller fails structured instead of falling through to help.
|
||||
func flagTokensInArgs(rawArgs []string) []string {
|
||||
var toks []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
toks = append(toks, a)
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
||||
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
||||
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
||||
// the suggestion path; the side effect is that flags before a subcommand are
|
||||
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
||||
// them in a "did you mean" envelope.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
||||
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
||||
// group and therefore not requiring a subcommand.
|
||||
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
lookup := func(fs *pflag.FlagSet) bool {
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
||||
}
|
||||
|
||||
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
||||
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
||||
// omitting the subcommand they belong to (`im --format json`). Global flags
|
||||
// valid on the bare group (e.g. --profile) are excluded so
|
||||
// `lark-cli --profile p im` still prints help rather than erroring.
|
||||
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var misplaced []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name == "" || flagKnownOnGroup(cmd, name) {
|
||||
continue
|
||||
}
|
||||
if flagDefinedInTree(cmd, name) {
|
||||
misplaced = append(misplaced, a)
|
||||
}
|
||||
}
|
||||
return misplaced
|
||||
}
|
||||
|
||||
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
known := func(c *cobra.Command, inherited bool) bool {
|
||||
fs := c.Flags()
|
||||
if inherited {
|
||||
fs = c.InheritedFlags()
|
||||
}
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
if known(cmd, false) || known(cmd, true) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if known(c, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -354,10 +576,95 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(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.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||
// the valid_flags detail).
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
|
||||
@@ -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,149 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFlagTokens(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
|
||||
// the subcommand) is distinguished from a genuinely unknown one.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rawArgs []string
|
||||
want []string
|
||||
}{
|
||||
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
|
||||
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
|
||||
{"no flags at all", []string{"drive"}, nil},
|
||||
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
|
||||
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := unknownFlagTokens(drive, tc.rawArgs)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
|
||||
// --badflag, so RunE sees no args; the guard must recover it from
|
||||
// rawInvocationArgs and fail structured rather than print help + exit 0.
|
||||
rawInvocationArgs = []string{"drive", "--badflag"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||
}
|
||||
|
||||
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
|
||||
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["unknown"] != "--badflag" {
|
||||
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
|
||||
}
|
||||
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
|
||||
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
|
||||
}
|
||||
for _, key := range []string{"suggestions", "valid_flags"} {
|
||||
if _, present := detail[key]; !present {
|
||||
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// --query is defined on the +search subcommand, so it is a *valid* flag that
|
||||
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
|
||||
// must still fail structured (missing_subcommand) rather than fall through to
|
||||
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"drive", "--query", "x"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
|
||||
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
|
||||
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
|
||||
}
|
||||
if detail["command_path"] != "lark-cli drive" {
|
||||
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
|
||||
}
|
||||
}
|
||||
|
||||
// A bare group carrying only a group-valid global flag (e.g. the inherited
|
||||
// --profile) is not missing a subcommand — those flags do not belong to a
|
||||
// subcommand — so it must print help, not fail with missing_subcommand.
|
||||
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"--profile", "p", "drive"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
var buf bytes.Buffer
|
||||
drive.SetOut(&buf)
|
||||
drive.SetErr(&buf)
|
||||
if err := drive.RunE(drive, nil); err != nil {
|
||||
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drive ops") {
|
||||
t.Errorf("expected help output, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
@@ -113,11 +257,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "+secret") {
|
||||
t.Error("hidden commands must not appear in the hint")
|
||||
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
|
||||
// back to pointing at --help; the full machine-readable list lives in
|
||||
// detail.available below (which also excludes hidden commands).
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--help") {
|
||||
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
@@ -164,7 +308,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got := availableSubcommandNames(root)
|
||||
got, _ := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
@@ -175,3 +319,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
available, deprecated := availableSubcommandNames(root)
|
||||
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||
}
|
||||
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||
// separate detail buckets, while suggestions still rank across both so a
|
||||
// mistyped legacy alias resolves.
|
||||
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
138
lint/errscontract/rule_no_legacy_common_helper_call.go
Normal file
138
lint/errscontract/rule_no_legacy_common_helper_call.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errscontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// migratedCommonHelperPaths lists source-tree prefixes whose command validation
|
||||
// has migrated to typed errs.* envelopes. On these paths, calls to common's
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/drive/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
var legacyCommonHelperReplacements = map[string]string{
|
||||
"FlagErrorf": "common.ValidationErrorf",
|
||||
"MutuallyExclusive": "common.MutuallyExclusiveTyped",
|
||||
"AtLeastOne": "common.AtLeastOneTyped",
|
||||
"ExactlyOne": "common.ExactlyOneTyped",
|
||||
"ValidatePageSize": "common.ValidatePageSizeTyped",
|
||||
"ValidateChatID": "common.ValidateChatIDTyped",
|
||||
"ValidateUserID": "common.ValidateUserIDTyped",
|
||||
"ValidateSafePath": "common.ValidateSafePathTyped",
|
||||
"RejectDangerousChars": "common.RejectDangerousCharsTyped",
|
||||
"WrapInputStatError": "common.WrapInputStatErrorTyped",
|
||||
"WrapSaveErrorByCategory": "common.WrapSaveErrorTyped",
|
||||
"ResolveOpenIDs": "common.ResolveOpenIDsTyped",
|
||||
"HandleApiResult": "runtime.CallAPITyped",
|
||||
}
|
||||
|
||||
// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper
|
||||
// APIs on migrated paths — direct calls and function-value references alike,
|
||||
// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These
|
||||
// helpers return legacy output envelopes or bare errors, so migrated domains
|
||||
// should use their typed-aware replacements.
|
||||
func CheckNoLegacyCommonHelperCall(path, src string) []Violation {
|
||||
if !isMigratedCommonHelperPath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
localNames, dotImported := resolveCommonNames(file)
|
||||
var out []Violation
|
||||
report := func(pos token.Pos, name, replacement string) {
|
||||
out = append(out, Violation{
|
||||
Rule: "no_legacy_common_helper_call",
|
||||
Action: ActionReject,
|
||||
File: path,
|
||||
Line: fset.Position(pos).Line,
|
||||
Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths",
|
||||
Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor",
|
||||
})
|
||||
}
|
||||
// Pass 1: qualified references (common.X / alias.X). Record every
|
||||
// selector field so the dot-import pass below never mistakes another
|
||||
// package's same-named field for a common helper.
|
||||
selFields := make(map[*ast.Ident]struct{})
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selFields[sel.Sel] = struct{}{}
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, bound := localNames[x.Name]; !bound {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[sel.Sel.Name]; ok {
|
||||
report(sel.Pos(), sel.Sel.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Pass 2: unqualified references under a dot import.
|
||||
if dotImported {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
ident, ok := n.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if _, isField := selFields[ident]; isField {
|
||||
return true
|
||||
}
|
||||
if replacement, ok := legacyCommonHelperReplacements[ident.Name]; ok {
|
||||
report(ident.Pos(), ident.Name, replacement)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isMigratedCommonHelperPath(path string) bool {
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, prefix := range migratedCommonHelperPaths {
|
||||
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveCommonNames(file *ast.File) (map[string]struct{}, bool) {
|
||||
names := make(map[string]struct{})
|
||||
dotImported := false
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
p := strings.Trim(imp.Path.Value, "`\"")
|
||||
if p != commonImportPath {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case imp.Name == nil:
|
||||
names["common"] = struct{}{}
|
||||
case imp.Name.Name == ".":
|
||||
dotImported = true
|
||||
case imp.Name.Name == "_":
|
||||
default:
|
||||
names[imp.Name.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return names, dotImported
|
||||
}
|
||||
@@ -877,3 +877,123 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
t.Errorf("test files must be skipped, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
|
||||
helpers := []string{
|
||||
"FlagErrorf",
|
||||
"MutuallyExclusive",
|
||||
"AtLeastOne",
|
||||
"ExactlyOne",
|
||||
"ValidatePageSize",
|
||||
"ValidateChatID",
|
||||
"ValidateUserID",
|
||||
"ValidateSafePath",
|
||||
"RejectDangerousChars",
|
||||
"WrapInputStatError",
|
||||
"WrapSaveErrorByCategory",
|
||||
"ResolveOpenIDs",
|
||||
"HandleApiResult",
|
||||
}
|
||||
for _, helper := range helpers {
|
||||
t.Run(helper, func(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.` + helper + `()
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "common."+helper) {
|
||||
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.ValidationErrorf("typed")
|
||||
common.MutuallyExclusiveTyped(nil, "a", "b")
|
||||
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
|
||||
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
|
||||
common.WrapSaveErrorTyped(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("typed helpers must pass, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
c.FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import . "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
FlagErrorf("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() error {
|
||||
f := common.FlagErrorf
|
||||
return f("legacy")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func ScanRepo(root string) ([]Violation, error) {
|
||||
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
|
||||
all = append(all, CheckNoLegacyCommonHelperCall(rel, string(src))...)
|
||||
// Typed-error invariants — self-scope to errs/ + classify.go.
|
||||
all = append(all, CheckNilSafeError(rel, string(src))...)
|
||||
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.46",
|
||||
"version": "1.0.47",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
42
shortcuts/base/base_block_create.go
Normal file
42
shortcuts/base/base_block_create.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockCreate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-create",
|
||||
Description: "Create a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:create"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums},
|
||||
{Name: "name", Desc: "block name", Required: true},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, create at root"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type folder --name \"Project Docs\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type table --name \"Tasks\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type docx --name \"Spec\" --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type dashboard --name \"Metrics\"",
|
||||
"Example: lark-cli base +base-block-create --base-token <base_token> --type workflow --name \"Approval Flow\"",
|
||||
"Creates a folder, table, docx, dashboard, or workflow entry.",
|
||||
"Do not pass null for --parent-id. Omit it to create at the root level.",
|
||||
"Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockCreate(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockCreate,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockCreate(runtime)
|
||||
},
|
||||
}
|
||||
35
shortcuts/base/base_block_delete.go
Normal file
35
shortcuts/base/base_block_delete.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockDelete = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-delete",
|
||||
Description: "Delete a block",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:block:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Deletes the block identified by --block-id.",
|
||||
"Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.",
|
||||
"Different block types may have independent backing resources; deletion follows backend semantics.",
|
||||
"Use +base-block-list first when you need to confirm the target block id.",
|
||||
"If the user already explicitly confirmed this exact delete target, pass --yes without asking again.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockDelete(runtime)
|
||||
},
|
||||
}
|
||||
43
shortcuts/base/base_block_list.go
Normal file
43
shortcuts/base/base_block_list.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockList = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-list",
|
||||
Description: "List blocks in a base",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:block:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums},
|
||||
{Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token>",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"Example: lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`JQ crop: lark-cli base +base-block-list --base-token <base_token> | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`JQ crop docx: lark-cli base +base-block-list --base-token <base_token> --type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.",
|
||||
"For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
"For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.",
|
||||
"This command returns the full backend list. It intentionally does not expose limit or offset.",
|
||||
"Pass --type to list only one resource type.",
|
||||
"Pass --parent-id to list only direct children of a folder.",
|
||||
"Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.",
|
||||
},
|
||||
DryRun: dryRunBaseBlockList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockList(runtime)
|
||||
},
|
||||
}
|
||||
42
shortcuts/base/base_block_move.go
Normal file
42
shortcuts/base/base_block_move.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockMove = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-move",
|
||||
Description: "Move a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "parent-id", Desc: "target folder block id; when omitted, move to root"},
|
||||
{Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"},
|
||||
{Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
"Omit --parent-id to move the block to root; do not pass null.",
|
||||
"--before-id and --after-id are mutually exclusive.",
|
||||
"When moving a folder, its children remain under that folder.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockMove(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockMove,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockMove(runtime)
|
||||
},
|
||||
}
|
||||
179
shortcuts/base/base_block_ops.go
Normal file
179
shortcuts/base/base_block_ops.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"}
|
||||
|
||||
func baseBlockIDFlag(required bool) common.Flag {
|
||||
return common.Flag{Name: "block-id", Desc: "block id", Required: required}
|
||||
}
|
||||
|
||||
func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||
Body(buildBaseBlockListBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks").
|
||||
Body(buildBaseBlockCreateBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move").
|
||||
Body(buildBaseBlockMoveBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename").
|
||||
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("block_id", runtime.Str("block-id"))
|
||||
}
|
||||
|
||||
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return common.FlagErrorf("--type must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
|
||||
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockList(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type")))
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseBlockDelete(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func filterBaseBlockListData(data map[string]interface{}, blockType string) {
|
||||
if blockType == "" {
|
||||
return
|
||||
}
|
||||
blocks, ok := data["blocks"].([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filtered := make([]interface{}, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
blockMap, ok := block.(map[string]interface{})
|
||||
if !ok || blockMap["type"] != blockType {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
data["blocks"] = filtered
|
||||
data["total"] = len(filtered)
|
||||
}
|
||||
|
||||
func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"type": strings.TrimSpace(runtime.Str("type")),
|
||||
"name": strings.TrimSpace(runtime.Str("name")),
|
||||
}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{"parent_id": nil}
|
||||
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
|
||||
body["parent_id"] = parentID
|
||||
}
|
||||
if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" {
|
||||
body["before_id"] = beforeID
|
||||
}
|
||||
if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" {
|
||||
body["after_id"] = afterID
|
||||
}
|
||||
return body
|
||||
}
|
||||
37
shortcuts/base/base_block_rename.go
Normal file
37
shortcuts/base/base_block_rename.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var BaseBaseBlockRename = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+base-block-rename",
|
||||
Description: "Rename a block",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:block:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
baseBlockIDFlag(true),
|
||||
{Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name \"New name\"",
|
||||
"Renames the block identified by --block-id.",
|
||||
"Block names must be unique in the base; use +base-block-list first when you need to check existing names.",
|
||||
"Use +base-block-list first when you need to resolve the target block id from a visible name.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBaseBlockRename(runtime)
|
||||
},
|
||||
DryRun: dryRunBaseBlockRename,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseBlockRename(runtime)
|
||||
},
|
||||
}
|
||||
@@ -32,6 +32,29 @@ func TestDryRunTableOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
|
||||
}
|
||||
|
||||
func TestDryRunBaseBlockOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
|
||||
|
||||
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
|
||||
|
||||
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
|
||||
|
||||
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
|
||||
|
||||
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
|
||||
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
|
||||
}
|
||||
|
||||
func TestDryRunFieldOps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -411,6 +411,108 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
listStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
|
||||
},
|
||||
"total": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
|
||||
},
|
||||
}
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
|
||||
},
|
||||
}
|
||||
renameStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
|
||||
},
|
||||
}
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"block_id": "blk_doc"},
|
||||
},
|
||||
}
|
||||
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
|
||||
t.Fatalf("list err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
|
||||
t.Fatalf("list stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
|
||||
t.Fatalf("list body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("create stdout=%s", got)
|
||||
}
|
||||
createBody := decodeCapturedJSONBody(t, createStub)
|
||||
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
|
||||
t.Fatalf("create body=%#v", createBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
|
||||
t.Fatalf("move err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
|
||||
t.Fatalf("move stdout=%s", got)
|
||||
}
|
||||
moveBody := decodeCapturedJSONBody(t, moveStub)
|
||||
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
|
||||
t.Fatalf("move body=%#v", moveBody)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
|
||||
t.Fatalf("rename stdout=%s", got)
|
||||
}
|
||||
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
|
||||
t.Fatalf("rename body=%#v", body)
|
||||
}
|
||||
|
||||
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("delete err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
|
||||
t.Fatalf("delete stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -133,6 +133,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
|
||||
@@ -188,6 +189,7 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
|
||||
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
|
||||
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
|
||||
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
|
||||
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
|
||||
BaseRoleDelete.Command: BaseRoleDelete.Risk,
|
||||
}
|
||||
|
||||
@@ -241,6 +243,30 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(
|
||||
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
err := validateBaseBlockMove(runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
|
||||
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
|
||||
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("create err=%v", err)
|
||||
}
|
||||
|
||||
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
|
||||
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
|
||||
t.Fatalf("rename err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -728,6 +754,79 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantTips []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: BaseBaseBlockList,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-list --base-token <base_token>",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --type table",
|
||||
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
|
||||
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
|
||||
`--type docx | jq '.blocks[] | {name, docx_token}'`,
|
||||
"returned id is the table-id, dashboard-id, or workflow-id",
|
||||
"For docx blocks, use the returned docx_token with docx commands.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseBaseBlockCreate,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
|
||||
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
shortcut: BaseBaseBlockMove,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
|
||||
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rename",
|
||||
shortcut: BaseBaseBlockRename,
|
||||
wantTips: []string{
|
||||
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: BaseBaseBlockDelete,
|
||||
wantTips: []string{
|
||||
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
|
||||
"Recursive folder deletion is not supported.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
for _, want := range tt.wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
|
||||
@@ -8,6 +8,11 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
BaseBaseBlockRename,
|
||||
BaseBaseBlockDelete,
|
||||
BaseTableList,
|
||||
BaseTableGet,
|
||||
BaseTableCreate,
|
||||
|
||||
@@ -164,6 +164,9 @@ func CheckApiError(w io.Writer, result interface{}, action string) bool {
|
||||
}
|
||||
|
||||
// HandleApiResult checks for network/API errors and returns the "data" field.
|
||||
//
|
||||
// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for
|
||||
// self-driven requests) for typed error envelopes.
|
||||
func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RuntimeContext provides helpers for shortcut execution.
|
||||
@@ -72,6 +73,16 @@ func (ctx *RuntimeContext) IsBot() bool {
|
||||
return ctx.As().IsBot()
|
||||
}
|
||||
|
||||
// Command returns the shortcut command name as cobra knows it (e.g.
|
||||
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
|
||||
// validation) that key off the shortcut identity.
|
||||
func (ctx *RuntimeContext) Command() string {
|
||||
if ctx.Cmd == nil {
|
||||
return ""
|
||||
}
|
||||
return ctx.Cmd.Name()
|
||||
}
|
||||
|
||||
// UserOpenId returns the current user's open_id from config.
|
||||
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
||||
|
||||
@@ -200,6 +211,12 @@ func (ctx *RuntimeContext) Int(name string) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// Float64 returns a float64 flag value (non-integer numbers).
|
||||
func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
v, _ := ctx.Cmd.Flags().GetFloat64(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -625,6 +642,8 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||
// - Other errors → readMsg prefix (default "cannot read file")
|
||||
//
|
||||
// Pass an optional readMsg to override the non-path-validation message prefix.
|
||||
//
|
||||
// Deprecated: use WrapInputStatErrorTyped for typed error envelopes.
|
||||
func WrapInputStatError(err error, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -639,9 +658,28 @@ func WrapInputStatError(err error, readMsg ...string) error {
|
||||
return output.ErrValidation("%s: %s", msg, err)
|
||||
}
|
||||
|
||||
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file validation.
|
||||
func WrapInputStatErrorTyped(err error, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
|
||||
WithCause(err)
|
||||
}
|
||||
msg := "cannot read file"
|
||||
if len(readMsg) > 0 && readMsg[0] != "" {
|
||||
msg = readMsg[0]
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
|
||||
// using standardized messages and the given error category (e.g. "api_error", "io").
|
||||
// Path validation errors always use ErrValidation (exit code 2).
|
||||
//
|
||||
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
|
||||
func WrapSaveErrorByCategory(err error, category string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -657,6 +695,28 @@ func WrapSaveErrorByCategory(err error, category string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
|
||||
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
|
||||
// "internal" wire type: call sites migrating from a custom category
|
||||
// (e.g. "io", "api_error") change their envelope's type field.
|
||||
func WrapSaveErrorTyped(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
|
||||
WithCause(err)
|
||||
case errors.As(err, &me):
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).
|
||||
WithCause(err)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).
|
||||
WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePath checks that path is a valid relative input path within the
|
||||
// working directory by delegating to FileIO.Stat. Returns nil if the path is
|
||||
// valid or does not exist yet; returns an error only for illegal paths
|
||||
@@ -895,6 +955,29 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
return runShortcut(cmd, f, &shortcut, botOnly)
|
||||
},
|
||||
}
|
||||
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
|
||||
onInvoke := shortcut.OnInvoke
|
||||
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
|
||||
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
|
||||
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
|
||||
// surface even when the call later fails on a missing required flag.
|
||||
// - --print-schema: pure local introspection; relax the required-flag
|
||||
// gate so callers don't fill in unrelated flags just to ask for a
|
||||
// schema (clearing the annotation here is the supported opt-out).
|
||||
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
||||
if onInvoke != nil {
|
||||
onInvoke()
|
||||
}
|
||||
if relaxRequiredForSchema {
|
||||
if want, _ := c.Flags().GetBool("print-schema"); want {
|
||||
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
@@ -908,6 +991,31 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
||||
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
// --print-schema short-circuits everything below: it's pure local
|
||||
// introspection, no identity / scope / network needed. The flag is
|
||||
// only registered when the shortcut opts in via PrintFlagSchema.
|
||||
if s.PrintFlagSchema != nil {
|
||||
if want, _ := cmd.Flags().GetBool("print-schema"); want {
|
||||
flagName, _ := cmd.Flags().GetString("flag-name")
|
||||
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
|
||||
if err != nil {
|
||||
// PrintFlagSchema implementations return bare errors; wrap as a
|
||||
// structured ExitError so --print-schema (an agent-facing
|
||||
// introspection path) yields a parseable envelope, not a plain
|
||||
// string.
|
||||
if _, ok := err.(*output.ExitError); !ok {
|
||||
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.Out, string(out))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1012,6 +1120,16 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
||||
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
||||
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
||||
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
|
||||
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
|
||||
// occurrences are left untouched.
|
||||
func stripUTF8BOM(s string) string {
|
||||
return strings.TrimPrefix(s, "\uFEFF")
|
||||
}
|
||||
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
@@ -1022,7 +1140,8 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
|
||||
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if raw == "" {
|
||||
continue
|
||||
@@ -1031,17 +1150,23 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(fl.Input, Stdin) {
|
||||
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if stdinUsed {
|
||||
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
||||
// cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1054,17 +1179,23 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(fl.Input, File) {
|
||||
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
|
||||
return ValidationErrorf("--%s does not support file input (@path)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||
return ValidationErrorf("--%s: %v", fl.Name, err).
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -1088,7 +1219,8 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", "))
|
||||
return ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1096,7 +1228,8 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
|
||||
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if s.DryRun == nil {
|
||||
return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command)
|
||||
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
|
||||
WithParam("--dry-run")
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
|
||||
dryResult := s.DryRun(rctx.ctx, rctx)
|
||||
@@ -1149,6 +1282,10 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d int
|
||||
fmt.Sscanf(fl.Default, "%d", &d)
|
||||
cmd.Flags().Int(fl.Name, d, desc)
|
||||
case "float64":
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
@@ -1183,6 +1320,17 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
if s.PrintFlagSchema != nil {
|
||||
// Guard against a shortcut that already declares these reserved
|
||||
// introspection flags: pflag panics on a duplicate registration.
|
||||
// Mirrors the Lookup guard on --format above.
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,46 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved
|
||||
// --print-schema / --flag-name flags are registered defensively: a shortcut
|
||||
// that already declares same-named flags must not trigger pflag's duplicate-
|
||||
// registration panic (the Lookup guard in registerShortcutFlagsWithContext).
|
||||
func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+introspect",
|
||||
Description: "x",
|
||||
// The shortcut's own flags collide with the names the runner auto-
|
||||
// injects when PrintFlagSchema is set. Without the guard, pflag panics.
|
||||
Flags: []Flag{
|
||||
{Name: "print-schema", Desc: "user-defined collision"},
|
||||
{Name: "flag-name", Desc: "user-defined collision"},
|
||||
},
|
||||
PrintFlagSchema: func(string) ([]byte, error) { return nil, nil },
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r)
|
||||
}
|
||||
}()
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+introspect"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
t.Error("print-schema flag should still exist after the guarded registration")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
t.Error("flag-name flag should still exist after the guarded registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
|
||||
@@ -129,6 +129,7 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -142,6 +143,7 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -158,6 +160,7 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -171,6 +174,7 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -212,7 +216,58 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
22
shortcuts/common/runner_validation_test.go
Normal file
22
shortcuts/common/runner_validation_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEnumFlags_ReturnsTypedValidation(t *testing.T) {
|
||||
rctx := newTestRuntime(map[string]string{"mode": "delete"})
|
||||
err := validateEnumFlags(rctx, []Flag{
|
||||
{Name: "mode", Enum: []string{"append", "overwrite"}},
|
||||
})
|
||||
assertValidationParam(t, err, "--mode")
|
||||
}
|
||||
|
||||
func TestHandleShortcutDryRunUnsupported_ReturnsTypedValidation(t *testing.T) {
|
||||
err := handleShortcutDryRun(nil, nil, &Shortcut{
|
||||
Service: "doc",
|
||||
Command: "fetch",
|
||||
})
|
||||
assertValidationParam(t, err, "--dry-run")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -13,9 +14,32 @@ import (
|
||||
// open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName is
|
||||
// used in error messages to point the user at the offending CLI flag.
|
||||
//
|
||||
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
|
||||
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
|
||||
out, msg := resolveOpenIDs(flagName, ids, runtime)
|
||||
if msg != "" {
|
||||
return nil, output.ErrValidation("%s", msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveOpenIDsTyped expands the special identifier "me" to the current
|
||||
// user's open_id, removes duplicates case-insensitively while preserving the
|
||||
// first-occurrence form, and returns nil for an empty input. flagName names
|
||||
// the flag being resolved (e.g. "--user-ids") and is recorded on the typed
|
||||
// error.
|
||||
func ResolveOpenIDsTyped(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
|
||||
out, msg := resolveOpenIDs(flagName, ids, runtime)
|
||||
if msg != "" {
|
||||
return nil, ValidationErrorf("%s", msg).WithParam(flagName)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, string) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
return nil, ""
|
||||
}
|
||||
currentUserID := runtime.UserOpenId()
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
@@ -23,7 +47,7 @@ func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
|
||||
for _, id := range ids {
|
||||
if strings.EqualFold(id, "me") {
|
||||
if currentUserID == "" {
|
||||
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
|
||||
return nil, fmt.Sprintf("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
|
||||
}
|
||||
id = currentUserID
|
||||
}
|
||||
@@ -34,5 +58,5 @@ func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
return out, ""
|
||||
}
|
||||
|
||||
@@ -75,3 +75,24 @@ func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
|
||||
t.Fatalf("case-insensitive dedup failed: got %v, want [ou_abc123]", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_MeWithoutLogin_ReturnsTypedValidation(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("")
|
||||
_, err := ResolveOpenIDsTyped("--user-ids", []string{"me"}, rt)
|
||||
validationErr := assertValidationParam(t, err, "--user-ids")
|
||||
if !strings.Contains(validationErr.Message, "--user-ids") {
|
||||
t.Fatalf("error should mention the offending flag name; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenIDsTyped_ExpandsMeAndDedups(t *testing.T) {
|
||||
rt := resolveOpenIDsTestRuntime("ou_self")
|
||||
out, err := ResolveOpenIDsTyped("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []string{"ou_self", "ou_a"}
|
||||
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
|
||||
t.Fatalf("got %v, want %v", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,26 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// FlagErrorf returns a validation error with flag context (exit code 2).
|
||||
//
|
||||
// Deprecated: use ValidationErrorf for typed error envelopes.
|
||||
func FlagErrorf(format string, args ...any) error {
|
||||
return output.ErrValidation(format, args...)
|
||||
}
|
||||
|
||||
// ValidationErrorf returns a typed validation error with invalid_argument subtype.
|
||||
func ValidationErrorf(format string, args ...any) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
// MutuallyExclusive checks that at most one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use MutuallyExclusiveTyped for typed error envelopes.
|
||||
func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
|
||||
var set []string
|
||||
for _, f := range flags {
|
||||
@@ -32,7 +42,25 @@ func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MutuallyExclusiveTyped checks that at most one of the given flags is set.
|
||||
func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error {
|
||||
var set []string
|
||||
for _, f := range flags {
|
||||
val := rt.Str(f)
|
||||
if val != "" {
|
||||
set = append(set, "--"+f)
|
||||
}
|
||||
}
|
||||
if len(set) > 1 {
|
||||
return ValidationErrorf("%s are mutually exclusive", strings.Join(set, " and ")).
|
||||
WithParams(invalidParams(set, "mutually exclusive")...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AtLeastOne checks that at least one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use AtLeastOneTyped for typed error envelopes.
|
||||
func AtLeastOne(rt *RuntimeContext, flags ...string) error {
|
||||
for _, f := range flags {
|
||||
if rt.Str(f) != "" {
|
||||
@@ -46,7 +74,24 @@ func AtLeastOne(rt *RuntimeContext, flags ...string) error {
|
||||
return FlagErrorf("specify at least one of %s", strings.Join(names, " or "))
|
||||
}
|
||||
|
||||
// AtLeastOneTyped checks that at least one of the given flags is set.
|
||||
func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error {
|
||||
for _, f := range flags {
|
||||
if rt.Str(f) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
names := make([]string, len(flags))
|
||||
for i, f := range flags {
|
||||
names[i] = "--" + f
|
||||
}
|
||||
return ValidationErrorf("specify at least one of %s", strings.Join(names, " or ")).
|
||||
WithParams(invalidParams(names, "required; specify at least one")...)
|
||||
}
|
||||
|
||||
// ExactlyOne checks that exactly one of the given flags is set.
|
||||
//
|
||||
// Deprecated: use ExactlyOneTyped for typed error envelopes.
|
||||
func ExactlyOne(rt *RuntimeContext, flags ...string) error {
|
||||
if err := AtLeastOne(rt, flags...); err != nil {
|
||||
return err
|
||||
@@ -54,8 +99,18 @@ func ExactlyOne(rt *RuntimeContext, flags ...string) error {
|
||||
return MutuallyExclusive(rt, flags...)
|
||||
}
|
||||
|
||||
// ExactlyOneTyped checks that exactly one of the given flags is set.
|
||||
func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
|
||||
if err := AtLeastOneTyped(rt, flags...); err != nil {
|
||||
return err
|
||||
}
|
||||
return MutuallyExclusiveTyped(rt, flags...)
|
||||
}
|
||||
|
||||
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||
//
|
||||
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
|
||||
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||
s := rt.Str(flagName)
|
||||
if s == "" {
|
||||
@@ -71,6 +126,25 @@ func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, m
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||
s := rt.Str(flagName)
|
||||
param := "--" + flagName
|
||||
if s == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, ValidationErrorf("invalid --%s %q: must be an integer", flagName, s).WithParam(param)
|
||||
}
|
||||
if n < minVal || n > maxVal {
|
||||
return 0, ValidationErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal).
|
||||
WithParam(param)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ParseIntBounded parses an int flag and clamps it to [min, max].
|
||||
func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
|
||||
v := rt.Int(name)
|
||||
@@ -87,13 +161,26 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
|
||||
// working directory. It catches traversal, symlink escape, and control
|
||||
// characters by delegating to FileIO.ResolvePath. Works for both file and
|
||||
// directory paths.
|
||||
//
|
||||
// Deprecated: use ValidateSafePathTyped for typed error envelopes.
|
||||
func ValidateSafePath(fio fileio.FileIO, path string) error {
|
||||
_, err := fio.ResolvePath(path)
|
||||
return err
|
||||
}
|
||||
|
||||
// ValidateSafePathTyped ensures path resolves within the current working directory.
|
||||
func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
|
||||
_, err := fio.ResolvePath(path)
|
||||
if err != nil {
|
||||
return ValidationErrorf("%s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectDangerousChars returns an error if value contains ASCII control
|
||||
// characters or dangerous Unicode code points.
|
||||
//
|
||||
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
|
||||
func RejectDangerousChars(paramName, value string) error {
|
||||
for _, r := range value {
|
||||
if r < 0x20 && r != '\t' && r != '\n' {
|
||||
@@ -108,3 +195,31 @@ func RejectDangerousChars(paramName, value string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectDangerousCharsTyped returns an error if value contains ASCII control
|
||||
// characters or dangerous Unicode code points.
|
||||
func RejectDangerousCharsTyped(paramName, value string) error {
|
||||
for _, r := range value {
|
||||
if r < 0x20 && r != '\t' && r != '\n' {
|
||||
return ValidationErrorf("parameter %q contains control character U+%04X", paramName, r).
|
||||
WithParam(paramName)
|
||||
}
|
||||
if r == 0x7F {
|
||||
return ValidationErrorf("parameter %q contains DEL character", paramName).
|
||||
WithParam(paramName)
|
||||
}
|
||||
if IsDangerousUnicode(r) {
|
||||
return ValidationErrorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r).
|
||||
WithParam(paramName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidParams(names []string, reason string) []errs.InvalidParam {
|
||||
params := make([]errs.InvalidParam, len(names))
|
||||
for i, name := range names {
|
||||
params[i] = errs.InvalidParam{Name: name, Reason: reason}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -11,10 +11,31 @@ import (
|
||||
|
||||
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided.
|
||||
//
|
||||
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
|
||||
func ValidateChatID(input string) (string, error) {
|
||||
chatID, msg := normalizeChatID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided. param names the flag being
|
||||
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
||||
func ValidateChatIDTyped(param, input string) (string, error) {
|
||||
chatID, msg := normalizeChatID(input)
|
||||
if msg != "" {
|
||||
return "", ValidationErrorf("%s", msg).WithParam(param)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
func normalizeChatID(input string) (string, string) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", output.ErrValidation("chat ID cannot be empty")
|
||||
return "", "chat ID cannot be empty"
|
||||
}
|
||||
// Extract from URL if present
|
||||
if strings.Contains(input, "feishu.cn") || strings.Contains(input, "larksuite.com") {
|
||||
@@ -28,19 +49,40 @@ func ValidateChatID(input string) (string, error) {
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(input, "oc_") {
|
||||
return "", output.ErrValidation("invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)")
|
||||
return "", "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)"
|
||||
}
|
||||
return input, nil
|
||||
return input, ""
|
||||
}
|
||||
|
||||
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
|
||||
//
|
||||
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
|
||||
func ValidateUserID(input string) (string, error) {
|
||||
userID, msg := normalizeUserID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
|
||||
// param names the flag being validated (e.g. "--creator-ids") and is
|
||||
// recorded on the typed error.
|
||||
func ValidateUserIDTyped(param, input string) (string, error) {
|
||||
userID, msg := normalizeUserID(input)
|
||||
if msg != "" {
|
||||
return "", ValidationErrorf("%s", msg).WithParam(param)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func normalizeUserID(input string) (string, string) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", output.ErrValidation("user ID cannot be empty")
|
||||
return "", "user ID cannot be empty"
|
||||
}
|
||||
if !strings.HasPrefix(input, "ou_") {
|
||||
return "", output.ErrValidation("invalid user ID format, should start with 'ou_' (e.g., ou_abc123)")
|
||||
return "", "invalid user ID format, should start with 'ou_' (e.g., ou_abc123)"
|
||||
}
|
||||
return input, nil
|
||||
return input, ""
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -26,6 +30,24 @@ func newTestRuntime(flags map[string]string) *RuntimeContext {
|
||||
return &RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func assertValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("Subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if param != "" && validationErr.Param != param {
|
||||
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func TestMutuallyExclusive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -69,6 +91,109 @@ func TestMutuallyExclusive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorf_ReturnsTypedInvalidArgument(t *testing.T) {
|
||||
err := ValidationErrorf("bad %s", "flag")
|
||||
validationErr := assertValidationParam(t, err, "")
|
||||
if validationErr.Message != "bad flag" {
|
||||
t.Fatalf("Message = %q, want %q", validationErr.Message, "bad flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedFlagGroupHelpers_ReturnValidationParams(t *testing.T) {
|
||||
t.Run("mutually exclusive", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
|
||||
validationErr := assertValidationParam(t, MutuallyExclusiveTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
if validationErr.Params[0].Name != "--a" || validationErr.Params[1].Name != "--b" {
|
||||
t.Fatalf("Params names = %+v, want --a/--b", validationErr.Params)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("at least one", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "", "b": ""})
|
||||
validationErr := assertValidationParam(t, AtLeastOneTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
if !strings.Contains(validationErr.Message, "--a or --b") {
|
||||
t.Fatalf("Message = %q, want flag group", validationErr.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exactly one", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
|
||||
validationErr := assertValidationParam(t, ExactlyOneTyped(rt, "a", "b"), "")
|
||||
if len(validationErr.Params) != 2 {
|
||||
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatePageSizeTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{"page-size": "nope"})
|
||||
_, err := ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
|
||||
assertValidationParam(t, err, "--page-size")
|
||||
|
||||
rt = newTestRuntime(map[string]string{"page-size": "30"})
|
||||
_, err = ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
|
||||
assertValidationParam(t, err, "--page-size")
|
||||
}
|
||||
|
||||
func TestValidateIDTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
chatID, err := ValidateChatIDTyped("--chat-ids", "https://example.feishu.cn/foo/oc_abc")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateChatIDTyped valid URL: %v", err)
|
||||
}
|
||||
if chatID != "oc_abc" {
|
||||
t.Fatalf("chatID = %q, want oc_abc", chatID)
|
||||
}
|
||||
assertValidationParam(t, func() error {
|
||||
_, err := ValidateChatIDTyped("--chat-ids", "bad")
|
||||
return err
|
||||
}(), "--chat-ids")
|
||||
assertValidationParam(t, func() error {
|
||||
_, err := ValidateUserIDTyped("--creator-ids", "bad")
|
||||
return err
|
||||
}(), "--creator-ids")
|
||||
}
|
||||
|
||||
func TestRejectDangerousCharsTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
err := RejectDangerousCharsTyped("--query", "bad\x01")
|
||||
validationErr := assertValidationParam(t, err, "--query")
|
||||
if !strings.Contains(validationErr.Message, "control character") {
|
||||
t.Fatalf("Message = %q, want control character", validationErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapInputStatErrorTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
cause := &fileio.PathValidationError{Err: errors.New("outside cwd")}
|
||||
err := WrapInputStatErrorTyped(cause)
|
||||
validationErr := assertValidationParam(t, err, "")
|
||||
if !strings.Contains(validationErr.Message, "unsafe file path") {
|
||||
t.Fatalf("Message = %q, want unsafe file path", validationErr.Message)
|
||||
}
|
||||
if !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Fatalf("expected errors.Is(fileio.ErrPathValidation) to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
|
||||
pathErr := &fileio.PathValidationError{Err: errors.New("outside cwd")}
|
||||
assertValidationParam(t, WrapSaveErrorTyped(pathErr), "")
|
||||
|
||||
mkdirErr := &fileio.MkdirError{Err: errors.New("permission denied")}
|
||||
err := WrapSaveErrorTyped(mkdirErr)
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtLeastOne(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -246,3 +371,20 @@ func TestValidateSafePath_AllowsNonExistentPath(t *testing.T) {
|
||||
t.Fatalf("expected no error for non-existent path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateSafePathTyped_ReturnsTypedValidation verifies that an escaping
|
||||
// path is rejected with a typed validation error and a safe path passes.
|
||||
func TestValidateSafePathTyped_ReturnsTypedValidation(t *testing.T) {
|
||||
outside := t.TempDir()
|
||||
workDir := t.TempDir()
|
||||
chdirForTest(t, workDir)
|
||||
|
||||
if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
assertValidationParam(t, ValidateSafePathTyped(&localfileio.LocalFileIO{}, "evil_out"), "")
|
||||
|
||||
if err := ValidateSafePathTyped(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
|
||||
t.Fatalf("expected no error for safe path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,230 +39,296 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
})
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExport runs the CLI-level export constraint checks.
|
||||
func ValidateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -488,6 +488,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -34,128 +34,160 @@ var DriveImport = common.Shortcut{
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -354,7 +354,7 @@ func parseDriveSearchPageSize(raw string) (int, error) {
|
||||
// server-side failure or empty result.
|
||||
func validateDriveSearchIDs(spec driveSearchSpec) error {
|
||||
for _, id := range spec.CreatorIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--creator-ids", id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
|
||||
}
|
||||
for _, id := range spec.ChatIDs {
|
||||
if _, err := common.ValidateChatID(id); err != nil {
|
||||
if _, err := common.ValidateChatIDTyped("--chat-ids", id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
|
||||
}
|
||||
}
|
||||
@@ -370,7 +370,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
|
||||
}
|
||||
for _, id := range spec.SharerIDs {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
if _, err := common.ValidateUserIDTyped("--sharer-ids", id); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -196,8 +197,12 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
|
||||
|
||||
header, _ := card["header"].(cardObj)
|
||||
title := ""
|
||||
subtitle := ""
|
||||
headerTags := ""
|
||||
if header != nil {
|
||||
title = c.extractHeaderTitle(header)
|
||||
subtitle = c.extractHeaderSubtitle(header)
|
||||
headerTags = c.extractHeaderTags(header)
|
||||
}
|
||||
|
||||
bodyContent := ""
|
||||
@@ -206,13 +211,19 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
if title != "" {
|
||||
sb.WriteString("<card title=\"")
|
||||
sb.WriteString(cardEscapeAttr(title))
|
||||
sb.WriteString("\">\n")
|
||||
if title != "" && subtitle != "" {
|
||||
sb.WriteString(fmt.Sprintf("<card title=\"%s\" subtitle=\"%s\">\n", cardEscapeAttr(title), cardEscapeAttr(subtitle)))
|
||||
} else if title != "" {
|
||||
sb.WriteString(fmt.Sprintf("<card title=\"%s\">\n", cardEscapeAttr(title)))
|
||||
} else if subtitle != "" {
|
||||
sb.WriteString(fmt.Sprintf("<card subtitle=\"%s\">\n", cardEscapeAttr(subtitle)))
|
||||
} else {
|
||||
sb.WriteString("<card>\n")
|
||||
}
|
||||
if headerTags != "" {
|
||||
sb.WriteString(headerTags)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if bodyContent != "" {
|
||||
sb.WriteString(bodyContent)
|
||||
sb.WriteString("\n")
|
||||
@@ -233,6 +244,49 @@ func (c *cardConverter) extractHeaderTitle(header cardObj) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractHeaderSubtitle returns the subtitle text of a card header, supporting both
|
||||
// the property-wrapped and flat element formats.
|
||||
func (c *cardConverter) extractHeaderSubtitle(header cardObj) string {
|
||||
if prop, ok := header["property"].(cardObj); ok {
|
||||
if subtitleElem, ok := prop["subtitle"]; ok {
|
||||
return c.extractTextContent(subtitleElem)
|
||||
}
|
||||
}
|
||||
if subtitleElem, ok := header["subtitle"]; ok {
|
||||
return c.extractTextContent(subtitleElem)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractHeaderTags returns a space-joined string of header tag labels from textTagList,
|
||||
// supporting both property-wrapped and flat header formats.
|
||||
func (c *cardConverter) extractHeaderTags(header cardObj) string {
|
||||
var prop cardObj
|
||||
if p, ok := header["property"].(cardObj); ok {
|
||||
prop = p
|
||||
} else {
|
||||
prop = header
|
||||
}
|
||||
tagList, ok := prop["textTagList"].([]interface{})
|
||||
if !ok || len(tagList) == 0 {
|
||||
return ""
|
||||
}
|
||||
var tags []string
|
||||
for _, tag := range tagList {
|
||||
tm, ok := tag.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if text := c.convertElement(tm, 0); text != "" {
|
||||
tags = append(tags, text)
|
||||
}
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(tags, " ")
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertBody(body cardObj) string {
|
||||
var elements []interface{}
|
||||
|
||||
@@ -479,8 +533,11 @@ func (c *cardConverter) convertDiv(prop cardObj, _ string) string {
|
||||
|
||||
if textElem, ok := prop["text"].(cardObj); ok {
|
||||
if text := c.convertElement(textElem, 0); text != "" {
|
||||
if textSize, _ := textElem["text_size"].(string); textSize == "notation" {
|
||||
text = "📝 " + text
|
||||
textProp := c.extractProperty(textElem)
|
||||
if textStyle, ok := textProp["textStyle"].(cardObj); ok {
|
||||
if size, _ := textStyle["size"].(string); size == "notation" {
|
||||
text = "📝 " + text
|
||||
}
|
||||
}
|
||||
results = append(results, text)
|
||||
}
|
||||
@@ -558,7 +615,14 @@ func (c *cardConverter) convertEmoji(prop cardObj) string {
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertLocalDatetime(prop cardObj) string {
|
||||
if ms, ok := prop["milliseconds"].(string); ok && ms != "" {
|
||||
var ms string
|
||||
switch v := prop["milliseconds"].(type) {
|
||||
case string:
|
||||
ms = v
|
||||
case float64:
|
||||
ms = strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
if ms != "" {
|
||||
if formatted := cardFormatMillisToISO8601(ms); formatted != "" {
|
||||
return formatted
|
||||
}
|
||||
@@ -789,22 +853,22 @@ func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string {
|
||||
}
|
||||
}
|
||||
|
||||
shouldExpand := expanded || c.mode == cardModeDetailed
|
||||
if shouldExpand {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("▼ " + title + "\n")
|
||||
if elements, ok := prop["elements"].([]interface{}); ok {
|
||||
content := c.convertElements(elements, 1)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if line != "" {
|
||||
sb.WriteString(" " + line + "\n")
|
||||
}
|
||||
indicator := "▶"
|
||||
if expanded {
|
||||
indicator = "▼"
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(indicator + " " + title + "\n")
|
||||
if elements, ok := prop["elements"].([]interface{}); ok {
|
||||
content := c.convertElements(elements, 1)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if line != "" {
|
||||
sb.WriteString(" " + line + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("▲")
|
||||
return sb.String()
|
||||
}
|
||||
return "▶ " + title
|
||||
sb.WriteString("▲")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string {
|
||||
@@ -852,27 +916,7 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
|
||||
}
|
||||
|
||||
disabled, _ := prop["disabled"].(bool)
|
||||
if disabled && c.mode == cardModeConcise {
|
||||
return fmt.Sprintf("[%s ✗]", buttonText)
|
||||
}
|
||||
|
||||
if actions, ok := prop["actions"].([]interface{}); ok {
|
||||
for _, action := range actions {
|
||||
am, ok := action.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if am["type"] == "open_url" {
|
||||
if ad, ok := am["action"].(cardObj); ok {
|
||||
if urlStr, ok := ad["url"].(string); ok && urlStr != "" {
|
||||
return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if disabled && c.mode == cardModeDetailed {
|
||||
if disabled {
|
||||
result := fmt.Sprintf("[%s ✗]", buttonText)
|
||||
if tips, ok := prop["disabledTips"].(cardObj); ok {
|
||||
if tipsText := c.extractTextContent(tips); tipsText != "" {
|
||||
@@ -882,7 +926,42 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
|
||||
return result
|
||||
}
|
||||
|
||||
return fmt.Sprintf("[%s]", buttonText)
|
||||
result := fmt.Sprintf("[%s]", buttonText)
|
||||
if actions, ok := prop["actions"].([]interface{}); ok {
|
||||
for _, action := range actions {
|
||||
am, ok := action.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if am["type"] == "open_url" {
|
||||
if ad, ok := am["action"].(cardObj); ok {
|
||||
if urlStr, ok := ad["url"].(string); ok && urlStr != "" {
|
||||
result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if confirmObj, ok := prop["confirm"].(cardObj); ok {
|
||||
var parts []string
|
||||
if titleElem, ok := confirmObj["title"]; ok {
|
||||
if t := c.extractTextContent(titleElem); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
if textElem, ok := confirmObj["text"]; ok {
|
||||
if t := c.extractTextContent(textElem); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
result += fmt.Sprintf("(confirm:\"%s\")", strings.Join(parts, ": "))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertActions(prop cardObj) string {
|
||||
@@ -914,11 +993,33 @@ func (c *cardConverter) convertOverflow(prop cardObj) string {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text := ""
|
||||
if textElem, ok := om["text"].(cardObj); ok {
|
||||
if text := c.extractTextContent(textElem); text != "" {
|
||||
optTexts = append(optTexts, text)
|
||||
text = c.extractTextContent(textElem)
|
||||
}
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
urlStr := ""
|
||||
if actions, ok := om["actions"].([]interface{}); ok {
|
||||
for _, a := range actions {
|
||||
am, ok := a.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if am["type"] == "open_url" {
|
||||
if ad, ok := am["action"].(cardObj); ok {
|
||||
urlStr, _ = ad["url"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if urlStr != "" {
|
||||
text = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr)
|
||||
} else if value, _ := om["value"].(string); value != "" {
|
||||
text += "(" + value + ")"
|
||||
}
|
||||
optTexts = append(optTexts, text)
|
||||
}
|
||||
return "⋮ " + strings.Join(optTexts, ", ")
|
||||
}
|
||||
@@ -958,17 +1059,20 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value, _ := om["value"].(string)
|
||||
optText := ""
|
||||
if textElem, ok := om["text"].(cardObj); ok {
|
||||
optText = c.extractTextContent(textElem)
|
||||
}
|
||||
if optText == "" {
|
||||
optText, _ = om["value"].(string)
|
||||
optText = c.lookupOptionUserName(value)
|
||||
}
|
||||
if optText == "" {
|
||||
optText = value
|
||||
}
|
||||
if optText == "" {
|
||||
continue
|
||||
}
|
||||
value, _ := om["value"].(string)
|
||||
if selectedValues[value] {
|
||||
optText = "✓" + optText
|
||||
hasSelected = true
|
||||
@@ -989,17 +1093,15 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
|
||||
}
|
||||
|
||||
result := "{" + strings.Join(optionTexts, " / ") + "}"
|
||||
if c.mode == cardModeDetailed {
|
||||
var attrs []string
|
||||
if isMulti {
|
||||
attrs = append(attrs, "multi")
|
||||
}
|
||||
if strings.Contains(id, "person") {
|
||||
attrs = append(attrs, "type:person")
|
||||
}
|
||||
if len(attrs) > 0 {
|
||||
result += "(" + strings.Join(attrs, " ") + ")"
|
||||
}
|
||||
var attrs []string
|
||||
if isMulti {
|
||||
attrs = append(attrs, "multi")
|
||||
}
|
||||
if c.mode == cardModeDetailed && strings.Contains(id, "person") {
|
||||
attrs = append(attrs, "type:person")
|
||||
}
|
||||
if len(attrs) > 0 {
|
||||
result += "(" + strings.Join(attrs, " ") + ")"
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1025,6 +1127,17 @@ func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string {
|
||||
}
|
||||
value, _ := om["value"].(string)
|
||||
text := fmt.Sprintf("🖼️ Image %d", i+1)
|
||||
if value != "" {
|
||||
text += "(" + value + ")"
|
||||
}
|
||||
if imageID, ok := om["imageID"].(string); ok && imageID != "" {
|
||||
originKey, imgToken := c.getImageKeyAndToken(imageID)
|
||||
if originKey != "" {
|
||||
text += "(img_key:" + originKey + ")"
|
||||
} else if imgToken != "" {
|
||||
text += "(img_token:" + imgToken + ")"
|
||||
}
|
||||
}
|
||||
if selectedValues[value] {
|
||||
text = "✓" + text
|
||||
}
|
||||
@@ -1127,13 +1240,14 @@ func (c *cardConverter) convertImage(prop cardObj, _ string) string {
|
||||
}
|
||||
|
||||
result := "🖼️ " + alt
|
||||
if c.mode == cardModeDetailed {
|
||||
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
|
||||
if token := c.getImageToken(imageID); token != "" {
|
||||
result += "(img_token:" + token + ")"
|
||||
} else {
|
||||
result += "(img_key:" + imageID + ")"
|
||||
}
|
||||
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
|
||||
originKey, imgToken := c.getImageKeyAndToken(imageID)
|
||||
if originKey != "" {
|
||||
result += "(img_key:" + originKey + ")"
|
||||
} else if imgToken != "" {
|
||||
result += "(img_token:" + imgToken + ")"
|
||||
} else {
|
||||
result += "(img_key:" + imageID + ")"
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -1145,20 +1259,25 @@ func (c *cardConverter) convertImgCombination(prop cardObj) string {
|
||||
return ""
|
||||
}
|
||||
result := fmt.Sprintf("🖼️ %d image(s)", len(imgList))
|
||||
if c.mode == cardModeDetailed {
|
||||
var keys []string
|
||||
for _, img := range imgList {
|
||||
im, ok := img.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
|
||||
var keys []string
|
||||
for _, img := range imgList {
|
||||
im, ok := img.(cardObj)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
|
||||
originKey, imgToken := c.getImageKeyAndToken(imageID)
|
||||
if originKey != "" {
|
||||
keys = append(keys, originKey)
|
||||
} else if imgToken != "" {
|
||||
keys = append(keys, imgToken)
|
||||
} else {
|
||||
keys = append(keys, imageID)
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
result += "(keys:" + strings.Join(keys, ",") + ")"
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
result += "(keys:" + strings.Join(keys, ",") + ")"
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1176,7 +1295,11 @@ func (c *cardConverter) convertChart(prop cardObj, _ string) string {
|
||||
if ct, ok := chartSpec["type"].(string); ok && ct != "" {
|
||||
chartType = ct
|
||||
if typeName, ok := cardChartTypeNames[ct]; ok {
|
||||
title += typeName
|
||||
if title != "Chart" {
|
||||
title += " (" + typeName + ")"
|
||||
} else {
|
||||
title = typeName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1194,12 +1317,25 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
dataObj, ok := chartSpec["data"].(cardObj)
|
||||
if !ok {
|
||||
return ""
|
||||
|
||||
// VChart spec: data is an array of series objects ([{"id":"...","values":[...]}]).
|
||||
// Older/object format: data is a map with a "values" key directly.
|
||||
var values []interface{}
|
||||
switch d := chartSpec["data"].(type) {
|
||||
case cardObj:
|
||||
if v, ok := d["values"].([]interface{}); ok {
|
||||
values = v
|
||||
}
|
||||
case []interface{}:
|
||||
for _, series := range d {
|
||||
if sm, ok := series.(cardObj); ok {
|
||||
if v, ok := sm["values"].([]interface{}); ok {
|
||||
values = append(values, v...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
values, ok := dataObj["values"].([]interface{})
|
||||
if !ok || len(values) == 0 {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1244,28 +1380,24 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
|
||||
|
||||
func (c *cardConverter) convertAudio(prop cardObj, _ string) string {
|
||||
result := "🎵 Audio"
|
||||
if c.mode == cardModeDetailed {
|
||||
fileID, _ := prop["fileID"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = prop["audioID"].(string)
|
||||
}
|
||||
if fileID != "" {
|
||||
result += "(key:" + fileID + ")"
|
||||
}
|
||||
fileID, _ := prop["fileID"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = prop["audioID"].(string)
|
||||
}
|
||||
if fileID != "" {
|
||||
result += "(key:" + fileID + ")"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertVideo(prop cardObj, _ string) string {
|
||||
result := "🎬 Video"
|
||||
if c.mode == cardModeDetailed {
|
||||
fileID, _ := prop["fileID"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = prop["videoID"].(string)
|
||||
}
|
||||
if fileID != "" {
|
||||
result += "(key:" + fileID + ")"
|
||||
}
|
||||
fileID, _ := prop["fileID"].(string)
|
||||
if fileID == "" {
|
||||
fileID, _ = prop["videoID"].(string)
|
||||
}
|
||||
if fileID != "" {
|
||||
result += "(key:" + fileID + ")"
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1323,9 +1455,14 @@ func (c *cardConverter) convertTable(prop cardObj) string {
|
||||
func (c *cardConverter) extractTableCellValue(data interface{}) string {
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
// Lark API serialises array-type cell data as a Go-format string like
|
||||
// "[map[text:VIP] map[text:Premium]]". Detect and extract text values.
|
||||
if texts := goMapArrayTexts(v); len(texts) > 0 {
|
||||
return strings.Join(texts, ", ")
|
||||
}
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', 2, 64)
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case []interface{}:
|
||||
var texts []string
|
||||
for _, item := range v {
|
||||
@@ -1346,6 +1483,47 @@ func (c *cardConverter) extractTableCellValue(data interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
// goMapNextKey matches the start of the next key in a Go fmt map literal (space + identifier + colon).
|
||||
var goMapNextKey = regexp.MustCompile(` [a-zA-Z_][a-zA-Z0-9_]*:`)
|
||||
|
||||
// goMapArrayTexts extracts "text" values from a Go-format slice-of-maps string,
|
||||
// e.g. "[map[text:VIP] map[text:Premium]]" → ["VIP", "Premium"].
|
||||
// Values may contain spaces; they are delimited by the next map key or by "]".
|
||||
// Returns nil if the string doesn't look like this format.
|
||||
func goMapArrayTexts(s string) []string {
|
||||
if !strings.HasPrefix(s, "[") || !strings.Contains(s, "map[") {
|
||||
return nil
|
||||
}
|
||||
const key = "text:"
|
||||
var texts []string
|
||||
rest := s
|
||||
for {
|
||||
idx := strings.Index(rest, key)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
after := rest[idx+len(key):]
|
||||
bracketEnd := strings.Index(after, "]")
|
||||
nextKey := goMapNextKey.FindStringIndex(after)
|
||||
var end int
|
||||
if nextKey != nil && (bracketEnd < 0 || nextKey[0] < bracketEnd) {
|
||||
end = nextKey[0]
|
||||
} else if bracketEnd >= 0 {
|
||||
end = bracketEnd
|
||||
} else {
|
||||
if after != "" {
|
||||
texts = append(texts, after)
|
||||
}
|
||||
break
|
||||
}
|
||||
if val := after[:end]; val != "" {
|
||||
texts = append(texts, val)
|
||||
}
|
||||
rest = after[end:]
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
|
||||
userID, _ := prop["userID"].(string)
|
||||
if userID == "" {
|
||||
@@ -1359,14 +1537,14 @@ func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
|
||||
}
|
||||
if personName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
|
||||
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
|
||||
}
|
||||
return "@" + personName
|
||||
return personName
|
||||
}
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("@user(open_id:%s)", userID)
|
||||
return fmt.Sprintf("user(open_id:%s)", userID)
|
||||
}
|
||||
return "@" + userID
|
||||
return userID
|
||||
}
|
||||
|
||||
// convertPersonV1 handles the v1 card schema person element.
|
||||
@@ -1382,14 +1560,14 @@ func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string {
|
||||
personName := c.lookupPersonName(userID)
|
||||
if personName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
|
||||
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
|
||||
}
|
||||
return "@" + personName
|
||||
return personName
|
||||
}
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("@user(open_id:%s)", userID)
|
||||
return fmt.Sprintf("user(open_id:%s)", userID)
|
||||
}
|
||||
return "@" + userID
|
||||
return userID
|
||||
}
|
||||
|
||||
func (c *cardConverter) convertPersonList(prop cardObj) string {
|
||||
@@ -1404,10 +1582,21 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
|
||||
continue
|
||||
}
|
||||
personID, _ := pm["id"].(string)
|
||||
if c.mode == cardModeDetailed && personID != "" {
|
||||
names = append(names, fmt.Sprintf("@user(id:%s)", personID))
|
||||
personName := c.lookupPersonName(personID)
|
||||
if personName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
names = append(names, fmt.Sprintf("%s(open_id:%s)", personName, personID))
|
||||
} else {
|
||||
names = append(names, personName)
|
||||
}
|
||||
} else if personID != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
names = append(names, fmt.Sprintf("user(id:%s)", personID))
|
||||
} else {
|
||||
names = append(names, personID)
|
||||
}
|
||||
} else {
|
||||
names = append(names, "@user")
|
||||
names = append(names, "user")
|
||||
}
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
@@ -1415,8 +1604,15 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
|
||||
|
||||
func (c *cardConverter) convertAvatar(prop cardObj, _ string) string {
|
||||
userID, _ := prop["userID"].(string)
|
||||
personName := c.lookupPersonName(userID)
|
||||
if personName != "" {
|
||||
if c.mode == cardModeDetailed {
|
||||
return fmt.Sprintf("👤 %s(open_id:%s)", personName, userID)
|
||||
}
|
||||
return "👤 " + personName
|
||||
}
|
||||
result := "👤"
|
||||
if c.mode == cardModeDetailed && userID != "" {
|
||||
if userID != "" {
|
||||
result += "(id:" + userID + ")"
|
||||
}
|
||||
return result
|
||||
@@ -1497,20 +1693,37 @@ func (c *cardConverter) lookupPersonName(userID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *cardConverter) getImageToken(imageID string) string {
|
||||
// lookupOptionUserName resolves a user display name from the attachment's option_users map,
|
||||
// used for person-selector option labels.
|
||||
func (c *cardConverter) lookupOptionUserName(userID string) string {
|
||||
if c.attachment == nil {
|
||||
return ""
|
||||
}
|
||||
if images, ok := c.attachment["images"].(cardObj); ok {
|
||||
if imageInfo, ok := images[imageID].(cardObj); ok {
|
||||
if token, ok := imageInfo["token"].(string); ok {
|
||||
return token
|
||||
if optUsers, ok := c.attachment["option_users"].(cardObj); ok {
|
||||
if userInfo, ok := optUsers[userID].(cardObj); ok {
|
||||
if content, ok := userInfo["content"].(string); ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getImageKeyAndToken returns the origin_key and token for an image ID from the attachment map.
|
||||
// origin_key takes priority over token as the display-ready image reference.
|
||||
func (c *cardConverter) getImageKeyAndToken(imageID string) (originKey, token string) {
|
||||
if c.attachment == nil {
|
||||
return "", ""
|
||||
}
|
||||
if images, ok := c.attachment["images"].(cardObj); ok {
|
||||
if imageInfo, ok := images[imageID].(cardObj); ok {
|
||||
originKey, _ = imageInfo["origin_key"].(string)
|
||||
token, _ = imageInfo["token"].(string)
|
||||
}
|
||||
}
|
||||
return originKey, token
|
||||
}
|
||||
|
||||
type cardTextStyle struct {
|
||||
bold bool
|
||||
italic bool
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2620,3 +2620,45 @@ func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMessageIDs parses and validates the existing +messages comma-separated
|
||||
// flag format. Unlike splitByComma, it keeps empty entries so "id1,,id2" fails
|
||||
// locally. It intentionally does not enforce the server-side single-call limit:
|
||||
// fetchFullMessages chunks backend requests into batches of 20.
|
||||
func validateMessageIDs(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, output.ErrValidation("--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if part != id {
|
||||
return nil, output.ErrValidation("--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateBatchGetMessageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return nil, output.ErrValidation("--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func validateBatchGetMessageID(id string, index int) error {
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return output.ErrValidation("--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
decoded, err := base64.URLEncoding.DecodeString(id)
|
||||
if err != nil || len(decoded) == 0 {
|
||||
return output.ErrValidation("--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ package mail
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -19,7 +18,8 @@ type mailMessagesOutput struct {
|
||||
}
|
||||
|
||||
// MailMessages is the `+messages` shortcut: batch-fetch full content for
|
||||
// up to 20 message IDs in a single call, preserving request order.
|
||||
// multiple message IDs, chunking backend calls into batches of 20 while
|
||||
// preserving request order.
|
||||
var MailMessages = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+messages",
|
||||
@@ -35,11 +35,15 @@ var MailMessages = common.Shortcut{
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotMailboxNotMe(runtime)
|
||||
if err := validateBotMailboxNotMe(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := validateMessageIDs(runtime.Str("message-ids"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
messageIDs := splitByComma(runtime.Str("message-ids"))
|
||||
messageIDs, _ := validateMessageIDs(runtime.Str("message-ids"))
|
||||
body := map[string]interface{}{
|
||||
"format": messageGetFormat(runtime.Bool("html")),
|
||||
"message_ids": []string{"<message_id_1>", "<message_id_2>"},
|
||||
@@ -59,9 +63,9 @@ var MailMessages = common.Shortcut{
|
||||
}
|
||||
mailboxID := resolveMailboxID(runtime)
|
||||
hintIdentityFirst(runtime, mailboxID)
|
||||
messageIDs := splitByComma(runtime.Str("message-ids"))
|
||||
if len(messageIDs) == 0 {
|
||||
return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
|
||||
messageIDs, err := validateMessageIDs(runtime.Str("message-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
html := runtime.Bool("html")
|
||||
|
||||
|
||||
92
shortcuts/mail/mail_messages_test.go
Normal file
92
shortcuts/mail/mail_messages_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestMailMessagesExecuteChunksMoreThanTwentyIDs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
ids := make([]string, 21)
|
||||
for i := range ids {
|
||||
ids[i] = base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("biz-%03d", i)))
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/batch_get",
|
||||
BodyFilter: requestMessageIDsEqual(ids[:20]),
|
||||
Body: batchGetMessagesResponse(ids[:20]),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/batch_get",
|
||||
BodyFilter: requestMessageIDsEqual(ids[20:]),
|
||||
Body: batchGetMessagesResponse(ids[20:]),
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--message-ids", strings.Join(ids, ","),
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
out := decodeShortcutEnvelopeData(t, stdout)
|
||||
if got := int(out["total"].(float64)); got != len(ids) {
|
||||
t.Fatalf("total = %d, want %d; stdout=%s", got, len(ids), stdout.String())
|
||||
}
|
||||
messages, ok := out["messages"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("messages has unexpected type %T", out["messages"])
|
||||
}
|
||||
if len(messages) != len(ids) {
|
||||
t.Fatalf("messages length = %d, want %d", len(messages), len(ids))
|
||||
}
|
||||
for i, item := range messages {
|
||||
msg, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("messages[%d] has unexpected type %T", i, item)
|
||||
}
|
||||
if got := msg["message_id"]; got != ids[i] {
|
||||
t.Fatalf("messages[%d].message_id = %v, want %s", i, got, ids[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestMessageIDsEqual(want []string) func([]byte) bool {
|
||||
return func(body []byte) bool {
|
||||
var payload struct {
|
||||
MessageIDs []string `json:"message_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(payload.MessageIDs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func batchGetMessagesResponse(ids []string) map[string]interface{} {
|
||||
messages := make([]map[string]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
messages = append(messages, map[string]interface{}{
|
||||
"message_id": id,
|
||||
"subject": id,
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"messages": messages,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -133,7 +134,7 @@ func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
|
||||
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
|
||||
"+messages", "--as", "bot", "--message-ids", validMessageIDForTest("biz-x"),
|
||||
}, f, stdout)
|
||||
assertValidationError(t, err, "does not support --mailbox me")
|
||||
}
|
||||
@@ -142,7 +143,7 @@ func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
|
||||
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
|
||||
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", validMessageIDForTest("biz-x"),
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
@@ -182,3 +183,87 @@ func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
|
||||
}, f, stdout)
|
||||
assertValidatePasses(t, err)
|
||||
}
|
||||
|
||||
// --- message_ids validation tests (S2) ---
|
||||
|
||||
func validMessageIDForTest(s string) string {
|
||||
return base64.URLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
|
||||
_, err := validateMessageIDs(validMessageIDForTest("biz-001") + "," + validMessageIDForTest("biz-002"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for valid IDs, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsEmpty(t *testing.T) {
|
||||
_, err := validateMessageIDs("")
|
||||
assertValidationError(t, err, "--message-ids is required")
|
||||
_, err = validateMessageIDs(" ")
|
||||
assertValidationError(t, err, "--message-ids is required")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsAcceptsMoreThanSingleBackendBatch(t *testing.T) {
|
||||
ids := make([]string, 21)
|
||||
for i := range ids {
|
||||
ids[i] = validMessageIDForTest(string(rune('a' + i)))
|
||||
}
|
||||
_, err := validateMessageIDs(strings.Join(ids, ","))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for more than one backend batch, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsEmptyEntry(t *testing.T) {
|
||||
_, err := validateMessageIDs(validMessageIDForTest("biz-1") + ",," + validMessageIDForTest("biz-2"))
|
||||
assertValidationError(t, err, "entry 2 is empty")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsLeadingOrTrailingWhitespace(t *testing.T) {
|
||||
id1 := validMessageIDForTest("biz-1")
|
||||
id2 := validMessageIDForTest("biz-2")
|
||||
_, err := validateMessageIDs(id1 + ", " + id2)
|
||||
assertValidationError(t, err, "must not contain leading or trailing whitespace")
|
||||
_, err = validateMessageIDs(" " + id1 + "," + id2)
|
||||
assertValidationError(t, err, "must not contain leading or trailing whitespace")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsDuplicateIDs(t *testing.T) {
|
||||
id := validMessageIDForTest("biz-1")
|
||||
_, err := validateMessageIDs(id + "," + id)
|
||||
assertValidationError(t, err, "duplicate message ID is not allowed")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsJSONLikeInput(t *testing.T) {
|
||||
_, err := validateMessageIDs(`["id1","id2"]`)
|
||||
assertValidationError(t, err, "expected a base64url")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsColonJoinedInput(t *testing.T) {
|
||||
_, err := validateMessageIDs("id1:id2")
|
||||
assertValidationError(t, err, "expected a base64url")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsRejectsNumericPrimaryID(t *testing.T) {
|
||||
_, err := validateMessageIDs("123456789")
|
||||
assertValidationError(t, err, "numeric primary IDs are not supported")
|
||||
}
|
||||
|
||||
func TestValidateMessageIDsAcceptsExactlyTwenty(t *testing.T) {
|
||||
ids := make([]string, 20)
|
||||
for i := range ids {
|
||||
ids[i] = validMessageIDForTest(string(rune('A' + i)))
|
||||
}
|
||||
_, err := validateMessageIDs(strings.Join(ids, ","))
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for exactly 20 IDs, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageIDRejectsInvalidBase64(t *testing.T) {
|
||||
_, err := validateMessageIDs("msg 1")
|
||||
assertValidationError(t, err, "expected a base64url")
|
||||
_, err = validateMessageIDs("not-base64!")
|
||||
assertValidationError(t, err, "expected a base64url")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/markdown"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
"github.com/larksuite/cli/shortcuts/task"
|
||||
"github.com/larksuite/cli/shortcuts/vc"
|
||||
@@ -64,6 +66,11 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, im.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
|
||||
// Backward-compatible sheets shortcuts (pre-refactor command names),
|
||||
// kept under shortcuts/sheets/backward so external callers relying on the
|
||||
// old `+create`, `+read`, `+write`, ... commands keep working alongside the
|
||||
// refactored ones. Command names are disjoint from sheets.Shortcuts().
|
||||
allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
@@ -146,6 +153,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
if service == "sheets" {
|
||||
applySheetsCompatGroups(svc)
|
||||
}
|
||||
|
||||
if !IsShortcutServiceAvailable(service, brand) {
|
||||
installBrandRestrictionGuard(svc, service, brand)
|
||||
@@ -189,3 +199,153 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
|
||||
// --help bypasses RunE, so surface the restriction in Long too.
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
// Sheets backward-compatibility help grouping.
|
||||
//
|
||||
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
||||
// users whose lark-sheets skill predates the refactor keep working even after
|
||||
// upgrading only the binary. In `sheets --help` those aliases would otherwise
|
||||
// sort alphabetically into the same flat list as the current commands,
|
||||
// indistinguishable from them. applySheetsCompatGroups splits them into a
|
||||
// dedicated cobra group whose heading tells the user to update their skill, and
|
||||
// appends a "(→ +new-command)" pointer to each alias so the migration target is
|
||||
// obvious. Pure presentation — the aliases stay fully executable.
|
||||
const (
|
||||
sheetsCurrentGroupID = "sheets-current"
|
||||
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
||||
// `sheets --help` grouping and the generic unknown-subcommand path
|
||||
// (cmd/root.go) classify these aliases the same way.
|
||||
sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID
|
||||
)
|
||||
|
||||
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
|
||||
// absent from this map still land in the deprecated group, just without a
|
||||
// pointer, so a missing entry degrades gracefully rather than misgrouping.
|
||||
var sheetsAliasReplacement = map[string]string{
|
||||
// spreadsheet / sheet management
|
||||
"+create": "+workbook-create",
|
||||
"+info": "+workbook-info",
|
||||
"+export": "+workbook-export",
|
||||
"+create-sheet": "+sheet-create",
|
||||
"+copy-sheet": "+sheet-copy",
|
||||
"+delete-sheet": "+sheet-delete",
|
||||
"+update-sheet": "+sheet-rename / +sheet-move / …",
|
||||
// cell data
|
||||
"+read": "+cells-get",
|
||||
"+write": "+cells-set",
|
||||
"+append": "+cells-set",
|
||||
"+find": "+cells-search",
|
||||
"+replace": "+cells-replace",
|
||||
// cell style / merge / image
|
||||
"+set-style": "+cells-set-style",
|
||||
"+batch-set-style": "+cells-batch-set-style",
|
||||
"+merge-cells": "+cells-merge",
|
||||
"+unmerge-cells": "+cells-unmerge",
|
||||
"+write-image": "+cells-set-image",
|
||||
// row / column dimensions
|
||||
"+add-dimension": "+dim-insert",
|
||||
"+insert-dimension": "+dim-insert",
|
||||
"+update-dimension": "+rows-resize / +dim-hide / …",
|
||||
"+move-dimension": "+dim-move",
|
||||
"+delete-dimension": "+dim-delete",
|
||||
// filter views (conditions folded into the view flags)
|
||||
"+create-filter-view": "+filter-view-create",
|
||||
"+update-filter-view": "+filter-view-update",
|
||||
"+list-filter-views": "+filter-view-list",
|
||||
"+get-filter-view": "+filter-view-list",
|
||||
"+delete-filter-view": "+filter-view-delete",
|
||||
"+create-filter-view-condition": "+filter-view-update",
|
||||
"+update-filter-view-condition": "+filter-view-update",
|
||||
"+list-filter-view-conditions": "+filter-view-list",
|
||||
"+get-filter-view-condition": "+filter-view-list",
|
||||
"+delete-filter-view-condition": "+filter-view-update",
|
||||
// dropdowns
|
||||
"+set-dropdown": "+dropdown-set",
|
||||
"+update-dropdown": "+dropdown-update",
|
||||
"+get-dropdown": "+dropdown-get",
|
||||
"+delete-dropdown": "+dropdown-delete",
|
||||
// float images (media-upload folded into create)
|
||||
"+media-upload": "+float-image-create",
|
||||
"+create-float-image": "+float-image-create",
|
||||
"+update-float-image": "+float-image-update",
|
||||
"+get-float-image": "+float-image-list",
|
||||
"+list-float-images": "+float-image-list",
|
||||
"+delete-float-image": "+float-image-delete",
|
||||
}
|
||||
|
||||
func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
svc.AddGroup(
|
||||
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
||||
&cobra.Group{
|
||||
ID: sheetsDeprecatedGroupID,
|
||||
Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update",
|
||||
},
|
||||
)
|
||||
|
||||
deprecated := make(map[string]struct{})
|
||||
for _, s := range sheetsbackward.Shortcuts() {
|
||||
deprecated[s.Command] = struct{}{}
|
||||
}
|
||||
|
||||
for _, c := range svc.Commands() {
|
||||
name := c.Name()
|
||||
if _, ok := deprecated[name]; ok {
|
||||
c.GroupID = sheetsDeprecatedGroupID
|
||||
if repl := sheetsAliasReplacement[name]; repl != "" {
|
||||
c.Short = c.Short + " (→ " + repl + ")"
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Only the refactored shortcuts (all "+"-prefixed) belong in the current
|
||||
// group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the
|
||||
// auto-added help/completion ungrouped so cobra files them under
|
||||
// "Additional Commands".
|
||||
if len(name) > 0 && name[0] == '+' {
|
||||
c.GroupID = sheetsCurrentGroupID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
||||
// alias so that invoking it records a process-level deprecation notice, which
|
||||
// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users
|
||||
// the --help grouping cannot: those whose pre-refactor skill calls +read /
|
||||
// +write directly and never reads --help. Replacement targets come from
|
||||
// sheetsAliasReplacement — the same single source of truth that drives the
|
||||
// "(→ +new)" help pointers.
|
||||
func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut {
|
||||
for i := range list {
|
||||
notice := &deprecation.Notice{
|
||||
Command: list[i].Command,
|
||||
Replacement: sheetsAliasReplacement[list[i].Command],
|
||||
Skill: "lark-sheets",
|
||||
}
|
||||
// Record the notice as soon as the command's own logic runs, so it is
|
||||
// surfaced even when Validate rejects the call — an out-of-date skill
|
||||
// can pass pre-refactor argument shapes (e.g. a range without the new
|
||||
// sheet-id prefix) and fail validation before Execute — and when
|
||||
// --dry-run short-circuits before Execute. Both hooks store the same
|
||||
// pointer, so setting it twice is harmless.
|
||||
if origValidate := list[i].Validate; origValidate != nil {
|
||||
list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
deprecation.SetPending(notice)
|
||||
return origValidate(ctx, runtime)
|
||||
}
|
||||
}
|
||||
if origExecute := list[i].Execute; origExecute != nil {
|
||||
list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
deprecation.SetPending(notice)
|
||||
return origExecute(ctx, runtime)
|
||||
}
|
||||
}
|
||||
// The Validate/Execute wrappers above miss one path: a cobra-level
|
||||
// required flag (MarkFlagRequired) that is absent fails at
|
||||
// ValidateRequiredFlags, before RunE — so neither hook runs and the
|
||||
// notice would be lost on exactly the "stale skill calls the old command
|
||||
// and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE,
|
||||
// ahead of ValidateRequiredFlags, so the notice still surfaces there.
|
||||
list[i].OnInvoke = func() { deprecation.SetPending(notice) }
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package shortcuts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -16,7 +17,9 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -471,3 +474,152 @@ func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
}
|
||||
t.Logf("wrote %d bytes to %s", len(data), output)
|
||||
}
|
||||
|
||||
// applySheetsCompatGroups must split the sheets service into a current group
|
||||
// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases),
|
||||
// append a "(→ +new)" migration pointer to each alias, and leave non-"+"
|
||||
// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them
|
||||
// under "Additional Commands".
|
||||
func TestApplySheetsCompatGroups(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"}
|
||||
aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"}
|
||||
metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"}
|
||||
svc.AddCommand(newCmd, aliasCmd, metaCmd)
|
||||
|
||||
applySheetsCompatGroups(svc)
|
||||
|
||||
if !svc.ContainsGroup(sheetsCurrentGroupID) {
|
||||
t.Errorf("current group %q not registered", sheetsCurrentGroupID)
|
||||
}
|
||||
if !svc.ContainsGroup(sheetsDeprecatedGroupID) {
|
||||
t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID)
|
||||
}
|
||||
if newCmd.GroupID != sheetsCurrentGroupID {
|
||||
t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID)
|
||||
}
|
||||
if aliasCmd.GroupID != sheetsDeprecatedGroupID {
|
||||
t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID)
|
||||
}
|
||||
if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") {
|
||||
t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short)
|
||||
}
|
||||
if metaCmd.GroupID != "" {
|
||||
t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
|
||||
// heading (telling users to update their skill) plus the per-alias migration
|
||||
// pointers, while keeping the refactored shortcuts under Available Commands.
|
||||
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
sheetsCmd, _, err := program.Find([]string{"sheets"})
|
||||
if err != nil {
|
||||
t.Fatalf("find sheets command: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
sheetsCmd.SetOut(&out)
|
||||
if err := sheetsCmd.Help(); err != nil {
|
||||
t.Fatalf("sheets help failed: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Available Commands:",
|
||||
"Deprecated pre-refactor commands",
|
||||
"update your lark-sheets skill",
|
||||
"+read",
|
||||
"(→ +cells-get)",
|
||||
"+write",
|
||||
"(→ +cells-set)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
||||
// invoking it records a process-level deprecation notice (reusing
|
||||
// sheetsAliasReplacement for the migration target) while still calling the
|
||||
// original Execute. cmd/root.go reads that notice into the JSON "_notice".
|
||||
func TestWrapSheetsBackwardDeprecation(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
called := false
|
||||
in := []common.Shortcut{{
|
||||
Service: "sheets",
|
||||
Command: "+read",
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
out := wrapSheetsBackwardDeprecation(in)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("wrapped list len = %d, want 1", len(out))
|
||||
}
|
||||
if deprecation.GetPending() != nil {
|
||||
t.Fatal("notice set before wrapped Execute ran")
|
||||
}
|
||||
|
||||
if err := out[0].Execute(context.Background(), nil); err != nil {
|
||||
t.Fatalf("wrapped Execute returned error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("original Execute was not invoked by the wrapper")
|
||||
}
|
||||
|
||||
dep := deprecation.GetPending()
|
||||
if dep == nil {
|
||||
t.Fatal("expected a pending deprecation notice after Execute")
|
||||
}
|
||||
if dep.Command != "+read" {
|
||||
t.Errorf("notice Command = %q, want +read", dep.Command)
|
||||
}
|
||||
if dep.Replacement != "+cells-get" {
|
||||
t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement)
|
||||
}
|
||||
if dep.Skill != "lark-sheets" {
|
||||
t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill)
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper must also decorate Validate, so an out-of-date skill whose
|
||||
// pre-refactor argument shape fails validation (before Execute) still gets the
|
||||
// deprecation notice in its error envelope.
|
||||
func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
validated := false
|
||||
in := []common.Shortcut{{
|
||||
Service: "sheets",
|
||||
Command: "+write",
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
validated = true
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
out := wrapSheetsBackwardDeprecation(in)
|
||||
if out[0].Validate == nil {
|
||||
t.Fatal("Validate hook was dropped by the wrapper")
|
||||
}
|
||||
if err := out[0].Validate(context.Background(), nil); err != nil {
|
||||
t.Fatalf("wrapped Validate returned error: %v", err)
|
||||
}
|
||||
if !validated {
|
||||
t.Fatal("original Validate was not invoked")
|
||||
}
|
||||
dep := deprecation.GetPending()
|
||||
if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" {
|
||||
t.Fatalf("Validate hook did not record expected notice: %#v", dep)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
919
shortcuts/sheets/batch_op_contract_test.go
Normal file
919
shortcuts/sheets/batch_op_contract_test.go
Normal file
@@ -0,0 +1,919 @@
|
||||
// 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: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
// Standalone cobra rejects 1.5 for an int flag at parse time;
|
||||
// mapFlagView.Int would silently truncate it to 1, so the batch
|
||||
// path must reject it too instead of executing on a floored index.
|
||||
name: "int flag given a non-integer number",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":2,"index":1.5}`,
|
||||
wantContains: "--index must be an integer",
|
||||
},
|
||||
{
|
||||
name: "bool flag given a string",
|
||||
subShortcut: "+cells-set",
|
||||
subInput: `{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]],"allow-overwrite":"true"}`,
|
||||
wantContains: "--allow-overwrite must be a boolean",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone
|
||||
// required-flag enforcement lives OUTSIDE the shared *Input builder — so it is
|
||||
// invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor:
|
||||
// - +csv-put: standalone requires one-of(start-cell, range) via cobra's
|
||||
// MarkFlagsOneRequired (PostMount); a batch sub-op never runs cobra.
|
||||
// - +sheet-move: standalone requires --index (>=0) and source-index>=0 in
|
||||
// SheetMove.Validate; the batch path uses a dedicated builder.
|
||||
//
|
||||
// Without an explicit guard, mapFlagView's flag-default fallback silently wins
|
||||
// (start-cell→"A1", index→0), so the batch sub-op diverges from the standalone
|
||||
// contract instead of failing.
|
||||
func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "+csv-put without start-cell or range",
|
||||
subShortcut: "+csv-put",
|
||||
subInput: `{"sheet-id":"sh1","csv":"a,b"}`,
|
||||
wantContains: "--start-cell or --range is required",
|
||||
},
|
||||
{
|
||||
name: "+sheet-move without index",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":2}`,
|
||||
wantContains: "requires index",
|
||||
},
|
||||
{
|
||||
name: "+sheet-move negative index",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":2,"index":-1}`,
|
||||
wantContains: "--index must be >= 0",
|
||||
},
|
||||
{
|
||||
name: "+sheet-move negative source-index",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":-1,"index":0}`,
|
||||
wantContains: "--source-index must be >= 0",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for
|
||||
// inputs that cobra's MarkFlagRequired catches on the standalone path,
|
||||
// the +batch-update sub-op (which has no cobra layer) must still reject
|
||||
// CLI-side with its own friendly error before issuing any API call. This
|
||||
// closes the original bug — a sub-op missing --sheet-id used to slip
|
||||
// through and surface as "sheet undefined not found" only after a
|
||||
// network round-trip.
|
||||
func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
"+cells-set missing --range",
|
||||
"+cells-set",
|
||||
`{"sheet-id":"sh1","cells":[[{"value":"x"}]]}`,
|
||||
"--range is required",
|
||||
},
|
||||
{
|
||||
"+dim-insert missing --position",
|
||||
"+dim-insert",
|
||||
`{"sheet-id":"sh1","count":1}`,
|
||||
"--position is required",
|
||||
},
|
||||
{
|
||||
"+rows-resize missing --type",
|
||||
"+rows-resize",
|
||||
`{"sheet-id":"sh1","range":"1:1"}`,
|
||||
"--type is required",
|
||||
},
|
||||
{
|
||||
"+range-copy missing --target-range",
|
||||
"+range-copy",
|
||||
`{"sheet-id":"sh1","source-range":"A1:B2"}`,
|
||||
"--target-range is required",
|
||||
},
|
||||
{
|
||||
"+sheet-rename missing --title",
|
||||
"+sheet-rename",
|
||||
`{"sheet-id":"sh1"}`,
|
||||
"--title is required",
|
||||
},
|
||||
{
|
||||
"+chart-update missing --chart-id",
|
||||
"+chart-update",
|
||||
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
|
||||
"--chart-id is required",
|
||||
},
|
||||
{
|
||||
"+filter-create missing --range",
|
||||
"+filter-create",
|
||||
`{"sheet-id":"sh1"}`,
|
||||
"--range is required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing --float-image-id",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
"--float-image-id is required",
|
||||
},
|
||||
// +float-image-update's core (image_name / position / size) is mandatory
|
||||
// on update too — the tool rejects without them and +float-image-list
|
||||
// can't backfill image_name. cobra gates these on the standalone path;
|
||||
// the batch sub-op must reject them here. The image source stays optional
|
||||
// (omitting it keeps the current image), so these inputs omit it.
|
||||
{
|
||||
"+float-image-update missing --image-name",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
"--image-name is required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing position",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","size-width":100,"size-height":50}`,
|
||||
"--position-row and --position-col are required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing size",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","position-row":0,"position-col":"A"}`,
|
||||
"--size-width and --size-height are required",
|
||||
},
|
||||
// +filter-{update,delete} need sheet-id (not sheet-name) because
|
||||
// server contract: filter_id === sheet_id, and we can't resolve
|
||||
// sheet-name → sheet-id mid-batch.
|
||||
{
|
||||
"+filter-update with --sheet-name only (filter_id must equal sheet_id)",
|
||||
"+filter-update",
|
||||
`{"sheet-name":"Sheet1","range":"A1:F1000","properties":{"rules":[]}}`,
|
||||
"+filter-update requires --sheet-id",
|
||||
},
|
||||
{
|
||||
"+filter-delete with --sheet-name only (filter_id must equal sheet_id)",
|
||||
"+filter-delete",
|
||||
`{"sheet-name":"Sheet1"}`,
|
||||
"+filter-delete requires --sheet-id",
|
||||
},
|
||||
// +sparkline-update requires sparkline_id on every
|
||||
// properties.sparklines[i] (server contract). CLI surfaces this
|
||||
// with a pointer to +sparkline-list so the agent doesn't have to
|
||||
// guess the id from an opaque server-side rejection.
|
||||
{
|
||||
"+sparkline-update item missing sparkline_id",
|
||||
"+sparkline-update",
|
||||
`{"sheet-id":"sh1","group-id":"g1","properties":{"sparklines":[{"position":{"row":0,"col":"A"}}]}}`,
|
||||
"missing sparkline_id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{
|
||||
"shortcut": tc.subShortcut,
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_SchemaValidatesSubOps confirms the schema-driven
|
||||
// validator fires on +batch-update sub-operations the same way it
|
||||
// fires on standalone shortcuts. mapFlagView.Command() returns the
|
||||
// sub-op's shortcut name, so validateInputAgainstSchema (called at
|
||||
// each input builder's tail) routes through the same (command, flag)
|
||||
// lookup pipeline a standalone invocation would. This regression
|
||||
// pins that wiring — without it, agents could slip past CLI-side
|
||||
// schema checks by wrapping a bad input in +batch-update.
|
||||
func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
// +pivot-create properties.values items enforce summarize_by
|
||||
// enum — schema rejects an out-of-enum value as a sub-op too.
|
||||
{
|
||||
"+pivot-create summarize_by out of enum",
|
||||
"+pivot-create",
|
||||
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
"summarize_by",
|
||||
},
|
||||
// +chart-create properties.position.row has minimum:0 — P0
|
||||
// addition; validator must catch -1 even in the batch path.
|
||||
{
|
||||
"+chart-create position.row below minimum",
|
||||
"+chart-create",
|
||||
`{"sheet-id":"sh1","properties":{"position":{"row":-1,"col":"A"},"size":{"width":400,"height":300}}}`,
|
||||
"below minimum",
|
||||
},
|
||||
// +cells-set --cells is a 2D array of objects per the
|
||||
// upstream-fixed schema; sub-op passing an object must be
|
||||
// rejected at the schema layer (not "expected JSON array").
|
||||
{
|
||||
"+cells-set cells wrong shape",
|
||||
"+cells-set",
|
||||
`{"sheet-id":"sh1","range":"A1","cells":{"foo":"bar"}}`,
|
||||
`expected type "array"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{
|
||||
"shortcut": tc.subShortcut,
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two
|
||||
// originally reported failures: +range-copy and +rows-resize sub-ops must
|
||||
// translate to the correct MCP body (not a near-passthrough that drops
|
||||
// required fields).
|
||||
func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// +range-copy → transform_range with range / destination_range (not the
|
||||
// raw source_range / target_range that used to leak through).
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" {
|
||||
t.Errorf("+range-copy sub-op body wrong: %#v", copyIn)
|
||||
}
|
||||
if copyIn["operation"] != "copy" {
|
||||
t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"])
|
||||
}
|
||||
|
||||
// +rows-resize → resize_range with range + resize_height. The CLI's single
|
||||
// "23" input must be expanded to "23:23" because resize_range rejects
|
||||
// bare single-element ranges.
|
||||
body = parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if resizeIn["range"] != "23:23" {
|
||||
t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"])
|
||||
}
|
||||
rh, _ := resizeIn["resize_height"].(map[string]interface{})
|
||||
if rh == nil || rh["type"] != "pixel" {
|
||||
t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RequiredFlagParity is the systematic standalone-vs-batch parity
|
||||
// contract: for EVERY batchable shortcut, a +batch-update sub-op that satisfies
|
||||
// the sheet locator but omits all of the shortcut's business-required flags must
|
||||
// fail in translateBatchOp — never silently fall back to a default. The earlier
|
||||
// cases (TestBatchOp_ErrorEquivalence / GuardsBeyondCobra) cover hand-picked
|
||||
// shortcuts; this one is data-driven over batchOpDispatch + flag-defs, so it
|
||||
// guards the whole surface and auto-covers any shortcut added later. If a future
|
||||
// refactor moves a required check out of the shared *Input builder (the exact
|
||||
// failure mode behind the csv-put / sheet-move gaps), the corresponding sub-op
|
||||
// would start accepting missing args and this test fails.
|
||||
func TestBatchOp_RequiredFlagParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagDefs: %v", err)
|
||||
}
|
||||
// Flags supplied by the +batch-update top level (url/token), or that form the
|
||||
// sub-op's own sheet selector, are context — not "business" inputs.
|
||||
locator := map[string]bool{
|
||||
"url": true, "spreadsheet-token": true,
|
||||
"sheet-id": true, "sheet-name": true,
|
||||
"target-sheet-id": true, "target-sheet-name": true,
|
||||
}
|
||||
// How each command expresses its sheet locator in a sub-op, so the error we
|
||||
// trigger is the business one, not a missing-locator error.
|
||||
sheetSel := func(cmd string) map[string]interface{} {
|
||||
switch cmd {
|
||||
case "+sheet-create": // create needs no existing-sheet anchor
|
||||
return map[string]interface{}{}
|
||||
case "+pivot-create": // placement selector is target-sheet-*; data source is --source
|
||||
return map[string]interface{}{"target-sheet-id": "sh1"}
|
||||
default:
|
||||
return map[string]interface{}{"sheet-id": "sh1"}
|
||||
}
|
||||
}
|
||||
for cmd := range batchOpDispatch {
|
||||
spec, ok := defs[cmd]
|
||||
if !ok {
|
||||
t.Errorf("%s is in batchOpDispatch but has no flag-defs entry", cmd)
|
||||
continue
|
||||
}
|
||||
var business []string
|
||||
for _, fl := range spec.Flags {
|
||||
if fl.Kind == "system" || locator[fl.Name] {
|
||||
continue
|
||||
}
|
||||
if fl.Required == "required" || fl.Required == "xor" {
|
||||
business = append(business, fl.Name)
|
||||
}
|
||||
}
|
||||
if len(business) == 0 {
|
||||
continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit
|
||||
}
|
||||
t.Run(cmd, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Errorf("%s: a sub-op omitting business-required %v was accepted; want an error "+
|
||||
"(batch must reject missing required flags, not silently default)", cmd, business)
|
||||
return
|
||||
}
|
||||
// The sub-op DID supply a sheet selector, so a missing-locator error
|
||||
// would mean the fixture is wrong and the business-required check never
|
||||
// actually ran — reject that shape so the parity check stays honest.
|
||||
if strings.Contains(err.Error(), "specify at least one of") {
|
||||
t.Errorf("%s: got a missing-locator error, not a business-required one (fixture bug): %v", cmd, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
348
shortcuts/sheets/batch_op_dispatch.go
Normal file
348
shortcuts/sheets/batch_op_dispatch.go
Normal file
@@ -0,0 +1,348 @@
|
||||
// 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},
|
||||
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
|
||||
}},
|
||||
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
|
||||
}},
|
||||
|
||||
// ─── 对象族 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
4799
shortcuts/sheets/data/flag-defs.json
Normal file
4799
shortcuts/sheets/data/flag-defs.json
Normal file
File diff suppressed because it is too large
Load Diff
6430
shortcuts/sheets/data/flag-schemas.json
Normal file
6430
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
|
||||
}
|
||||
82
shortcuts/sheets/flag_defs.go
Normal file
82
shortcuts/sheets/flag_defs.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── flag definitions, sourced from sheet-skill-spec ───────────────────
|
||||
//
|
||||
// data/flag-defs.json is the canonical, full definition of every CLI flag
|
||||
// (name, type, default, desc, enum, input, hidden, required, kind),
|
||||
// generated by sheet-skill-spec's sync script. The sync script also emits
|
||||
// flag_defs_gen.go — the compiled `flagDefs` map — so command startup pays
|
||||
// no JSON unmarshal (the parse cost used to land on every CLI invocation,
|
||||
// sheets or not). We build each shortcut's []common.Flag from flagDefs at
|
||||
// assembly time, so flag metadata never has to be hand-written in Go.
|
||||
//
|
||||
// Flags with kind == "system" (--dry-run, --yes, ...) are NOT materialized
|
||||
// here: the framework auto-injects them based on Risk / DryRun / HasFormat.
|
||||
// Do not hand-edit flag_defs_gen.go or data/flag-defs.json; regenerate via
|
||||
// the sync script. flag_defs_gen_test.go guards the two against drift.
|
||||
|
||||
type flagDef struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "public" | "own" | "system"
|
||||
Type string `json:"type"` // string | bool | int | int64 | float64 | string_array | string_slice
|
||||
Required string `json:"required"` // "required" | "optional" | "xor"
|
||||
Desc string `json:"desc"`
|
||||
Default string `json:"default"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Enum []string `json:"enum"`
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
|
||||
type commandDef struct {
|
||||
Risk string `json:"risk"`
|
||||
Flags []flagDef `json:"flags"`
|
||||
}
|
||||
|
||||
// loadFlagDefs returns the compiled flag definitions (flag_defs_gen.go).
|
||||
// The error return is always nil; it is retained so existing call sites that
|
||||
// handled a parse error keep compiling. There is no longer a runtime parse.
|
||||
func loadFlagDefs() (map[string]commandDef, error) {
|
||||
return flagDefs, nil
|
||||
}
|
||||
|
||||
// flagsFor builds the []common.Flag for a shortcut command directly from
|
||||
// flag-defs.json. System-kind flags are skipped (the framework injects
|
||||
// them). Panics if the command is absent or the JSON is malformed — this
|
||||
// is a build-time data contract, so a missing entry is a programming error
|
||||
// surfaced loudly at startup rather than a silent empty flag set.
|
||||
func flagsFor(command string) []common.Flag {
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("sheets: %v", err))
|
||||
}
|
||||
spec, ok := defs[command]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("sheets: no flag-defs.json entry for %q", command))
|
||||
}
|
||||
out := make([]common.Flag, 0, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" {
|
||||
continue
|
||||
}
|
||||
out = append(out, common.Flag{
|
||||
Name: df.Name,
|
||||
Type: df.Type,
|
||||
Default: df.Default,
|
||||
Desc: df.Desc,
|
||||
Hidden: df.Hidden,
|
||||
Required: df.Required == "required",
|
||||
Enum: df.Enum,
|
||||
Input: df.Input,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
988
shortcuts/sheets/flag_defs_gen.go
Normal file
988
shortcuts/sheets/flag_defs_gen.go
Normal file
@@ -0,0 +1,988 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-defs.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's
|
||||
// metadata for every shortcut, emitted as a Go literal so command startup
|
||||
// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate
|
||||
// with `go generate ./shortcuts/sheets/...` after data/flag-defs.json
|
||||
// changes.
|
||||
var flagDefs = map[string]commandDef{
|
||||
"+batch-update": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
|
||||
},
|
||||
},
|
||||
"+cells-batch-clear": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-batch-set-style": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
|
||||
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-clear": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-merge": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-replace": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
|
||||
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
|
||||
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
|
||||
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also replace within formula text"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace"},
|
||||
},
|
||||
},
|
||||
"+cells-search": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
|
||||
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
|
||||
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also search within formula text"},
|
||||
{Name: "max-matches", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 5000", Default: "5000", Hidden: true},
|
||||
{Name: "offset", Kind: "own", Type: "int", Required: "optional", Desc: "Skip the first N matches (for pagination); default 0", Default: "0"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
|
||||
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set-image": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set-style": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
|
||||
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-unmerge": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
"+chart-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cols-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+csv-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
"+csv-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-freeze": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-group": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
|
||||
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-hide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-insert": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
|
||||
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-ungroup": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-set": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select; default `false`"},
|
||||
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
|
||||
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
|
||||
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
|
||||
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
|
||||
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
|
||||
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
|
||||
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
|
||||
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
|
||||
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "xor", Desc: "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
|
||||
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
|
||||
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
|
||||
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
|
||||
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
|
||||
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
|
||||
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
|
||||
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", Input: []string{"file", "stdin"}},
|
||||
{Name: "target-position", Kind: "own", Type: "string", Required: "optional", Desc: "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", Default: "A1"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "xor", Desc: "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
|
||||
{Name: "target-sheet-name", Kind: "own", Type: "string", Required: "xor", Desc: "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
|
||||
{Name: "source", Kind: "own", Type: "string", Required: "required", Desc: "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-copy": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
{Name: "paste-type", Kind: "own", Type: "string", Required: "optional", Desc: "Paste content type (`+range-copy` only)", Default: "all", Enum: []string{"values", "formulas", "formats", "all"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-fill": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
|
||||
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-sort": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
|
||||
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+rows-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-copy": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-rename": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-set-tab-color": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool", Input: []string{"file", "stdin"}},
|
||||
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the header row written from column names (default true)", Default: "true"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+undo": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "steps", Kind: "own", Type: "int", Required: "optional", Desc: "Undo the most recent N edits made through this CLI link (default 1); one step = one prior write call", Default: "1"},
|
||||
{Name: "rev", Kind: "own", Type: "int", Required: "optional", Desc: "Undo anchor: the document revision returned by a prior write's response (`data.revision`). Omit to undo the latest edit. Doubles as an optimistic-concurrency check — rejected if the document has moved past this revision"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --headers/--values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the typed header row (only with --sheets; default true)", Default: "true"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-export": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-import": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
}
|
||||
33
shortcuts/sheets/flag_defs_gen_test.go
Normal file
33
shortcuts/sheets/flag_defs_gen_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// flagDefsJSONForTest embeds the source data only in tests; production code
|
||||
// reads the compiled flagDefs map (flag_defs_gen.go) and never unmarshals.
|
||||
//
|
||||
//go:embed data/flag-defs.json
|
||||
var flagDefsJSONForTest []byte
|
||||
|
||||
// TestFlagDefsGen_MatchesJSON guards against drift between the compiled
|
||||
// flagDefs map (flag_defs_gen.go) and its source data/flag-defs.json: if the
|
||||
// JSON is regenerated without re-running the codegen (or vice versa), this
|
||||
// fails. This equivalence is exactly what lets production code skip the
|
||||
// runtime unmarshal.
|
||||
func TestFlagDefsGen_MatchesJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
var fromJSON map[string]commandDef
|
||||
if err := json.Unmarshal(flagDefsJSONForTest, &fromJSON); err != nil {
|
||||
t.Fatalf("unmarshal flag-defs.json: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(fromJSON, flagDefs) {
|
||||
t.Error("compiled flagDefs differs from data/flag-defs.json; regenerate flag_defs_gen.go")
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
500
shortcuts/sheets/flag_schema_validate.go
Normal file
500
shortcuts/sheets/flag_schema_validate.go
Normal file
@@ -0,0 +1,500 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── schema-driven flag validation ────────────────────────────────────
|
||||
//
|
||||
// Composite JSON flags (--properties, --cells, --operations, …) carry
|
||||
// non-trivial payloads whose shape is already pinned by the embedded
|
||||
// data/flag-schemas.json (see flag_schema.go). Rather than hand-write
|
||||
// per-spec validators for type / enum / required / nested checks, every
|
||||
// such flag is run through validatePropertiesAgainstSchema after the
|
||||
// shortcut's enhance hook has filled in any flat-flag-derived fields
|
||||
// (schema describes the *final* tool input, not the raw --properties
|
||||
// JSON the user typed). Cross-field business rules that JSON Schema
|
||||
// can't express (e.g. sparkline-update requires sparkline_id per item)
|
||||
// continue to live in spec.validateUpdateInput.
|
||||
//
|
||||
// The rule set is a subset of ai-tools/.../validate-tool-params.ts —
|
||||
// type, enum, oneOf, required, nested properties, and array items.
|
||||
// additionalProperties is intentionally lenient: the embedded schema
|
||||
// is a sub-tree and may not be exhaustive, so rejecting unknown keys
|
||||
// would be more disruptive than valuable.
|
||||
|
||||
// validateParsedJSONFlag validates the just-parsed value of a single
|
||||
// JSON flag against its embedded schema, if one is registered for the
|
||||
// (command, flag) pair. Called from parseJSONFlag so every JSON flag
|
||||
// — sort-keys, options, border-styles, cells, operations, ranges, … —
|
||||
// is checked at the user-input boundary, in user-input shape.
|
||||
//
|
||||
// `properties` is intentionally skipped here: its schema describes the
|
||||
// *final* tool-input properties (the shape after enhance* hooks
|
||||
// inject flat-flag-derived fields such as cond-format's rule_type),
|
||||
// not what the user typed under --properties. The input-builder tail
|
||||
// validates that one via validateInputAgainstSchema after enhance.
|
||||
func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
||||
if fv == nil || value == nil {
|
||||
return nil
|
||||
}
|
||||
if _, skip := parseJSONFlagSkip[name]; skip {
|
||||
return nil
|
||||
}
|
||||
return validateValueAgainstSchema(fv, name, value)
|
||||
}
|
||||
|
||||
// parseJSONFlagSkip lists flag names where parseJSONFlag-time schema
|
||||
// validation is intentionally bypassed:
|
||||
//
|
||||
// - properties: schema describes the *final* tool-input shape (after
|
||||
// enhance hooks inject flat-flag-derived fields); validated at the
|
||||
// input-builder tail via validateInputAgainstSchema instead.
|
||||
// - operations: +batch-update's translator does richer validation
|
||||
// (allowed-shortcut allow-list, fan-out rejection, …) with more
|
||||
// actionable error messages than a generic "not in enum [...]"
|
||||
// would. The translator path stays the source of truth.
|
||||
var parseJSONFlagSkip = map[string]struct{}{
|
||||
"properties": {},
|
||||
"operations": {},
|
||||
}
|
||||
|
||||
// validateValueAgainstSchema is the (command, flag) → schema → check
|
||||
// pipeline shared by both validateParsedJSONFlag (user shape) and
|
||||
// validateInputAgainstSchema (wire shape).
|
||||
func validateValueAgainstSchema(fv flagView, name string, value interface{}) error {
|
||||
command := fv.Command()
|
||||
if command == "" {
|
||||
return nil
|
||||
}
|
||||
// Fast path: commands without a registered schema can't fail this check,
|
||||
// so skip the 256KB flag-schemas.json parse entirely for them.
|
||||
if _, ok := commandsWithSchema[command]; !ok {
|
||||
return nil
|
||||
}
|
||||
idx, _ := loadFlagSchemas()
|
||||
if idx == nil {
|
||||
return nil
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := entry[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
return common.FlagErrorf("--%s: %s", name, vErr.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateInputAgainstSchema validates input[flag] for every flag the
|
||||
// embedded schema registers under the view's shortcut command. Returns
|
||||
// nil when no schema is registered for the command, or when none of
|
||||
// the registered flag names appear in `input` (schema describes the
|
||||
// shape of values when they are present, not which flags must be
|
||||
// present). Designed to be called at the tail of every input builder
|
||||
// so wiring up a new shortcut requires only the standard one-line
|
||||
// invocation, not a per-shortcut validator.
|
||||
func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error {
|
||||
if fv == nil || input == nil {
|
||||
return nil
|
||||
}
|
||||
command := fv.Command()
|
||||
if command == "" {
|
||||
return nil
|
||||
}
|
||||
// Fast path: commands without a registered schema have nothing to
|
||||
// validate, so skip the 256KB flag-schemas.json parse entirely.
|
||||
if _, ok := commandsWithSchema[command]; !ok {
|
||||
return nil
|
||||
}
|
||||
idx, _ := loadFlagSchemas()
|
||||
if idx == nil {
|
||||
return nil
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok || len(entry) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deterministic order so error messages are stable across runs.
|
||||
flagNames := make([]string, 0, len(entry))
|
||||
for name := range entry {
|
||||
flagNames = append(flagNames, name)
|
||||
}
|
||||
sort.Strings(flagNames)
|
||||
|
||||
for _, flagName := range flagNames {
|
||||
if _, skip := inputSchemaSkip[flagName]; skip {
|
||||
continue
|
||||
}
|
||||
// Input keys are wire-style (underscore); schema keys are CLI-style
|
||||
// (hyphen) — translate before lookup. Flags whose wire form lives
|
||||
// under a different key (e.g. --sort-keys → sort_conditions) won't
|
||||
// be found here; they're already validated in user shape via
|
||||
// parseJSONFlag → validateParsedJSONFlag.
|
||||
inputKey := strings.ReplaceAll(flagName, "-", "_")
|
||||
value, present := input[inputKey]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if err := validateValueAgainstSchema(fv, flagName, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inputSchemaSkip mirrors parseJSONFlagSkip for the input-builder
|
||||
// tail. Same rationale: bypass schema validation for flags where
|
||||
// richer translator-side validation owns the contract (operations).
|
||||
var inputSchemaSkip = map[string]struct{}{
|
||||
"operations": {},
|
||||
}
|
||||
|
||||
// schemaProperty mirrors the JSON Schema subset used by
|
||||
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
|
||||
// they're documentation.
|
||||
//
|
||||
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
|
||||
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
|
||||
// "no bound declared" from "bound is zero".
|
||||
//
|
||||
// AdditionalProperties handles the JSON Schema three-way:
|
||||
// - absent / true → lenient, any extra key allowed (validator's
|
||||
// default; matches the file header's "may not be exhaustive"
|
||||
// stance for schemas that simply don't declare it).
|
||||
// - false → strict, every extra key rejected.
|
||||
// - <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)
|
||||
}
|
||||
}
|
||||
37
shortcuts/sheets/flag_schemas_gen.go
Normal file
37
shortcuts/sheets/flag_schemas_gen.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-schemas.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// commandsWithSchema is the set of shortcut commands that have at least one
|
||||
// introspectable composite flag in data/flag-schemas.json. Codegen'd so the
|
||||
// registration loop (shortcuts.go) and the validate fast-path can gate on it
|
||||
// without parsing the 256KB schema blob at startup (that parse used to run on
|
||||
// every CLI invocation, sheets or not). The 256KB is now only unmarshaled
|
||||
// on --print-schema or when validating a command that is in this set. Do not
|
||||
// hand-edit; regenerate with `go generate ./shortcuts/sheets/...`.
|
||||
var commandsWithSchema = map[string]struct{}{
|
||||
"+batch-update": {},
|
||||
"+cells-batch-set-style": {},
|
||||
"+cells-set": {},
|
||||
"+cells-set-style": {},
|
||||
"+chart-create": {},
|
||||
"+chart-update": {},
|
||||
"+cond-format-create": {},
|
||||
"+cond-format-update": {},
|
||||
"+dropdown-set": {},
|
||||
"+dropdown-update": {},
|
||||
"+filter-create": {},
|
||||
"+filter-update": {},
|
||||
"+filter-view-create": {},
|
||||
"+filter-view-update": {},
|
||||
"+pivot-create": {},
|
||||
"+pivot-update": {},
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
23
shortcuts/sheets/flag_schemas_gen_test.go
Normal file
23
shortcuts/sheets/flag_schemas_gen_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCommandsWithSchemaGen_MatchesJSON guards against drift between the
|
||||
// codegen'd commandsWithSchema set (flag_schemas_gen.go) and the actual keys
|
||||
// in data/flag-schemas.json — commandsWithFlagSchema() derives the set by
|
||||
// parsing the embedded blob. This equivalence is what lets registration and
|
||||
// the validate fast-path gate on the cheap set instead of parsing the 256KB
|
||||
// schema at startup.
|
||||
func TestCommandsWithSchemaGen_MatchesJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
fromJSON := commandsWithFlagSchema()
|
||||
if !reflect.DeepEqual(fromJSON, commandsWithSchema) {
|
||||
t.Error("commandsWithSchema differs from data/flag-schemas.json; regenerate flag_schemas_gen.go")
|
||||
}
|
||||
}
|
||||
321
shortcuts/sheets/flag_view.go
Normal file
321
shortcuts/sheets/flag_view.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// flagView is the read-only flag-accessor surface that every CLI-shape →
|
||||
// MCP-tool-body translator (the *Input builders) depends on. It is satisfied
|
||||
// as-is by *common.RuntimeContext (cobra-backed, used by standalone shortcut
|
||||
// execution) and by mapFlagView (map-backed, used by +batch-update sub-ops).
|
||||
//
|
||||
// Routing both paths through the same interface lets a sub-op inside
|
||||
// +batch-update reuse the exact same translator the standalone shortcut runs,
|
||||
// so the generated MCP body is identical either way (enforced by the
|
||||
// batch-vs-standalone contract test).
|
||||
type flagView interface {
|
||||
Str(name string) string
|
||||
Int(name string) int
|
||||
Float64(name string) float64
|
||||
Bool(name string) bool
|
||||
StrArray(name string) []string
|
||||
StrSlice(name string) []string
|
||||
Changed(name string) bool
|
||||
// Command returns the shortcut command this view feeds (e.g.
|
||||
// "+pivot-create"). Used to look up the schema entry for
|
||||
// schema-driven flag validation; both standalone and batch sub-op
|
||||
// paths populate it so a sub-op gets validated against the same
|
||||
// schema as the standalone shortcut.
|
||||
Command() string
|
||||
}
|
||||
|
||||
// mapFlagView adapts a +batch-update sub-op input object (decoded JSON) to the
|
||||
// flagView interface so the standalone *Input translators can consume it.
|
||||
//
|
||||
// Keys are matched leniently against the CLI flag name: a translator asking for
|
||||
// "source-range" finds either "source-range" or "source_range" in the map (the
|
||||
// reference docs use CLI flag names; users frequently send the underscore
|
||||
// form). Composite values (arrays / objects for flags like cells / properties /
|
||||
// sort-keys) are re-encoded to a JSON string on Str() so the downstream
|
||||
// parseJSONFlag round-trips them exactly as it would a CLI string argument.
|
||||
//
|
||||
// To mirror the standalone cobra layer exactly, value reads fall back to the
|
||||
// flag's declared default (seeded from flag-defs.json), while Changed() reflects
|
||||
// only what the user actually provided. This split matters because some
|
||||
// translators branch on Changed() (e.g. omit target_index unless --index was
|
||||
// set) and others read defaulted values (e.g. row-count defaults to 200).
|
||||
type mapFlagView struct {
|
||||
raw map[string]interface{} // user-supplied sub-op input (drives Changed)
|
||||
defaults map[string]interface{} // flag defaults (value fallback only)
|
||||
command string // shortcut command (e.g. "+chart-create"); used by schema validator
|
||||
}
|
||||
|
||||
func (m mapFlagView) Command() string { return m.command }
|
||||
|
||||
// newMapFlagViewForCommand wraps a sub-op input and seeds the value-fallback
|
||||
// defaults declared for `command` in flag-defs.json, so an absent flag resolves
|
||||
// to the same value the standalone cobra command would carry.
|
||||
func newMapFlagViewForCommand(command string, input map[string]interface{}) mapFlagView {
|
||||
fv := mapFlagView{raw: input, defaults: map[string]interface{}{}, command: command}
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
return fv
|
||||
}
|
||||
spec, ok := defs[command]
|
||||
if !ok {
|
||||
return fv
|
||||
}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Default == "" {
|
||||
continue
|
||||
}
|
||||
fv.defaults[df.Name] = typedDefault(df)
|
||||
}
|
||||
return fv
|
||||
}
|
||||
|
||||
// typedDefault converts a flag's string default to the Go type matching its
|
||||
// declared kind, so Int()/Bool()/Float64() see the right type.
|
||||
func typedDefault(df flagDef) interface{} {
|
||||
switch df.Type {
|
||||
case "bool":
|
||||
return df.Default == "true"
|
||||
case "int":
|
||||
var n int
|
||||
fmt.Sscanf(df.Default, "%d", &n)
|
||||
return n
|
||||
case "float64":
|
||||
var f float64
|
||||
fmt.Sscanf(df.Default, "%g", &f)
|
||||
return f
|
||||
default:
|
||||
return df.Default
|
||||
}
|
||||
}
|
||||
|
||||
// lookup resolves a flag name for a VALUE read: user input first (hyphen↔
|
||||
// underscore tolerant), then the seeded default. Returns the value and whether
|
||||
// it was found in either source.
|
||||
func (m mapFlagView) lookup(name string) (interface{}, bool) {
|
||||
if v, ok := m.lookupRaw(name); ok {
|
||||
return v, true
|
||||
}
|
||||
if m.defaults != nil {
|
||||
if v, ok := m.defaults[name]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// lookupRaw resolves a flag name against the user-supplied input only, trying
|
||||
// the exact key then the hyphen↔underscore variants.
|
||||
func (m mapFlagView) lookupRaw(name string) (interface{}, bool) {
|
||||
if v, ok := m.raw[name]; ok {
|
||||
return v, true
|
||||
}
|
||||
if alt := strings.ReplaceAll(name, "-", "_"); alt != name {
|
||||
if v, ok := m.raw[alt]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
||||
if v, ok := m.raw[alt]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m mapFlagView) Str(name string) string {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case bool, float64, int, int64:
|
||||
b, _ := json.Marshal(t)
|
||||
return string(b)
|
||||
default:
|
||||
// Arrays / objects (cells, properties, sort-keys, options, ...) are
|
||||
// re-encoded so the translator's parseJSONFlag re-parses them.
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func (m mapFlagView) Int(name string) int {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case int64:
|
||||
return int(t)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m mapFlagView) Float64(name string) float64 {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m mapFlagView) Bool(name string) bool {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (m mapFlagView) StrArray(name string) []string {
|
||||
return m.strSliceLike(name)
|
||||
}
|
||||
|
||||
func (m mapFlagView) StrSlice(name string) []string {
|
||||
return m.strSliceLike(name)
|
||||
}
|
||||
|
||||
func (m mapFlagView) strSliceLike(name string) []string {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok || v == nil {
|
||||
return nil
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case []string:
|
||||
return t
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
// CSV / comma-separated (matches cobra StringSlice behavior).
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(t, ",")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m mapFlagView) Changed(name string) bool {
|
||||
_, ok := m.lookupRaw(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// validateRawTypes rejects sub-op input fields whose JSON type contradicts the
|
||||
// flag's declared type in flag-defs. +batch-update skips parse-time schema
|
||||
// validation for `operations`, and Int/Float64/Bool silently fall back to
|
||||
// the zero value on a type mismatch — so without this guard a wrong-typed scalar
|
||||
// (e.g. "index":"abc" or "multiple":"true") would land as 0 / false instead of
|
||||
// erroring, writing to the wrong place. Only numeric and boolean flags are
|
||||
// checked; string and composite (array/object) flags stay permissive because
|
||||
// Str() intentionally coerces them and the translator/schema validates shape.
|
||||
//
|
||||
// Returns a bare error; the +batch-update translator wraps it with the
|
||||
// operations[i] (<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":
|
||||
// Int(): float64 → int(t) truncates, so a non-integer number would
|
||||
// be silently floored (1.9 → 1). Standalone cobra rejects it at
|
||||
// parse time; reject here too to keep batch/standalone parity.
|
||||
f, isNum := val.(float64)
|
||||
if !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
if math.Trunc(f) != f {
|
||||
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
|
||||
}
|
||||
case "float64":
|
||||
if _, isNum := val.(float64); !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
case "bool":
|
||||
if _, isBool := val.(bool); !isBool {
|
||||
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonTypeName names the JSON kind of a value decoded by encoding/json, for
|
||||
// type-mismatch error messages.
|
||||
func jsonTypeName(v interface{}) string {
|
||||
switch v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case bool:
|
||||
return "boolean"
|
||||
case float64:
|
||||
return "number"
|
||||
case string:
|
||||
return "string"
|
||||
case []interface{}:
|
||||
return "array"
|
||||
case map[string]interface{}:
|
||||
return "object"
|
||||
default:
|
||||
return fmt.Sprintf("%T", v)
|
||||
}
|
||||
}
|
||||
13
shortcuts/sheets/generate.go
Normal file
13
shortcuts/sheets/generate.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
// flag_defs_gen.go and flag_schemas_gen.go are generated from the canonical
|
||||
// data/*.json spec artifacts (synced from sheet-skill-spec). After the sync
|
||||
// script updates data/flag-defs.json or data/flag-schemas.json, regenerate
|
||||
// the compiled Go with:
|
||||
//
|
||||
// go generate ./shortcuts/sheets/...
|
||||
//
|
||||
//go:generate go run ./internal/gen
|
||||
@@ -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"
|
||||
)
|
||||
208
shortcuts/sheets/internal/gen/main.go
Normal file
208
shortcuts/sheets/internal/gen/main.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command gen regenerates flag_defs_gen.go and flag_schemas_gen.go from the
|
||||
// data/*.json spec artifacts, so command startup pays no JSON unmarshal.
|
||||
//
|
||||
// Invoked via `go generate ./shortcuts/sheets/...` (see ../../generate.go).
|
||||
// data/*.json stays the canonical source (synced from sheet-skill-spec); the
|
||||
// *_gen.go files are committed, derived artifacts. CI should run go generate
|
||||
// and fail on a dirty tree to keep them in lockstep.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type flagDef struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
Required string `json:"required"`
|
||||
Desc string `json:"desc"`
|
||||
Default string `json:"default"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Enum []string `json:"enum"`
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
|
||||
type commandDef struct {
|
||||
Risk string `json:"risk"`
|
||||
Flags []flagDef `json:"flags"`
|
||||
}
|
||||
|
||||
// sheetsDir resolves shortcuts/sheets from this generator's own location, so
|
||||
// the tool works regardless of the caller's working directory.
|
||||
func sheetsDir() string {
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
log.Fatal("gen: cannot resolve caller path")
|
||||
}
|
||||
// thisFile = <repo>/shortcuts/sheets/internal/gen/main.go
|
||||
return filepath.Join(filepath.Dir(thisFile), "..", "..")
|
||||
}
|
||||
|
||||
func writeFormatted(path string, b *bytes.Buffer) {
|
||||
out, err := format.Source(b.Bytes())
|
||||
if err != nil {
|
||||
log.Fatalf("gen: format %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", filepath.Base(path), len(out))
|
||||
}
|
||||
|
||||
func main() {
|
||||
dir := sheetsDir()
|
||||
genFlagDefs(dir)
|
||||
genFlagSchemas(dir)
|
||||
}
|
||||
|
||||
const flagDefsHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-defs.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's
|
||||
// metadata for every shortcut, emitted as a Go literal so command startup
|
||||
// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate
|
||||
// with ` + "`go generate ./shortcuts/sheets/...`" + ` after data/flag-defs.json
|
||||
// changes.
|
||||
var flagDefs = map[string]commandDef{
|
||||
`
|
||||
|
||||
func sliceLit(s []string) string {
|
||||
parts := make([]string, len(s))
|
||||
for i, v := range s {
|
||||
parts[i] = fmt.Sprintf("%q", v)
|
||||
}
|
||||
return "[]string{" + strings.Join(parts, ", ") + "}"
|
||||
}
|
||||
|
||||
func flagLit(f flagDef) string {
|
||||
var p []string
|
||||
if f.Name != "" {
|
||||
p = append(p, fmt.Sprintf("Name: %q", f.Name))
|
||||
}
|
||||
if f.Kind != "" {
|
||||
p = append(p, fmt.Sprintf("Kind: %q", f.Kind))
|
||||
}
|
||||
if f.Type != "" {
|
||||
p = append(p, fmt.Sprintf("Type: %q", f.Type))
|
||||
}
|
||||
if f.Required != "" {
|
||||
p = append(p, fmt.Sprintf("Required: %q", f.Required))
|
||||
}
|
||||
if f.Desc != "" {
|
||||
p = append(p, fmt.Sprintf("Desc: %q", f.Desc))
|
||||
}
|
||||
if f.Default != "" {
|
||||
p = append(p, fmt.Sprintf("Default: %q", f.Default))
|
||||
}
|
||||
if f.Hidden {
|
||||
p = append(p, "Hidden: true")
|
||||
}
|
||||
if f.Enum != nil {
|
||||
p = append(p, "Enum: "+sliceLit(f.Enum))
|
||||
}
|
||||
if f.Input != nil {
|
||||
p = append(p, "Input: "+sliceLit(f.Input))
|
||||
}
|
||||
return "{" + strings.Join(p, ", ") + "}"
|
||||
}
|
||||
|
||||
func genFlagDefs(dir string) {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-defs.json"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var defs map[string]commandDef
|
||||
if err := json.Unmarshal(raw, &defs); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(defs))
|
||||
for k := range defs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b bytes.Buffer
|
||||
b.WriteString(flagDefsHeader)
|
||||
for _, k := range keys {
|
||||
cd := defs[k]
|
||||
fmt.Fprintf(&b, "%q: {\n", k)
|
||||
if cd.Risk != "" {
|
||||
fmt.Fprintf(&b, "Risk: %q,\n", cd.Risk)
|
||||
}
|
||||
if cd.Flags != nil {
|
||||
b.WriteString("Flags: []flagDef{\n")
|
||||
for _, f := range cd.Flags {
|
||||
b.WriteString(flagLit(f))
|
||||
b.WriteString(",\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
|
||||
writeFormatted(filepath.Join(dir, "flag_defs_gen.go"), &b)
|
||||
}
|
||||
|
||||
const flagSchemasHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-schemas.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// commandsWithSchema is the set of shortcut commands that have at least one
|
||||
// introspectable composite flag in data/flag-schemas.json. Codegen'd so the
|
||||
// registration loop (shortcuts.go) and the validate fast-path can gate on it
|
||||
// without parsing the 256KB schema blob at startup (that parse used to run on
|
||||
// every CLI invocation, sheets or not). The 256KB is now only unmarshaled
|
||||
// on --print-schema or when validating a command that is in this set. Do not
|
||||
// hand-edit; regenerate with ` + "`go generate ./shortcuts/sheets/...`" + `.
|
||||
var commandsWithSchema = map[string]struct{}{
|
||||
`
|
||||
|
||||
func genFlagSchemas(dir string) {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-schemas.json"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Flags map[string]json.RawMessage `json:"flags"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(doc.Flags))
|
||||
for k := range doc.Flags {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b bytes.Buffer
|
||||
b.WriteString(flagSchemasHeader)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&b, "%q: {},\n", k)
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
|
||||
writeFormatted(filepath.Join(dir, "flag_schemas_gen.go"), &b)
|
||||
}
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user