mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3756f3642 | ||
|
|
27a2f2758b | ||
|
|
15ae1fabec | ||
|
|
d317493e49 | ||
|
|
a8f078478e | ||
|
|
06275415b1 | ||
|
|
b4c9c09de0 | ||
|
|
7fb71c6947 | ||
|
|
020aeb87ad |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.24] - 2026-05-06
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Add sheet management shortcuts (#722)
|
||||
- **base**: Support batch record get and delete (#630)
|
||||
- **task**: Add upload task attachment shortcut (#736)
|
||||
- **drive**: Pre-flight 10000-rune total cap for `+add-comment` `reply_elements` (#605)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Handle missing scopes and device flow improvements (#752)
|
||||
- Add url to markdown `+create` output (#753)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Refine field update conversion guidance (#748)
|
||||
|
||||
## [v1.0.23] - 2026-04-30
|
||||
|
||||
### Features
|
||||
@@ -579,6 +597,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.24]: https://github.com/larksuite/cli/releases/tag/v1.0.24
|
||||
[v1.0.23]: https://github.com/larksuite/cli/releases/tag/v1.0.23
|
||||
[v1.0.22]: https://github.com/larksuite/cli/releases/tag/v1.0.22
|
||||
[v1.0.21]: https://github.com/larksuite/cli/releases/tag/v1.0.21
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -42,7 +43,18 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
59
cmd/auth/list_test.go
Normal file
59
cmd/auth/list_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
|
||||
// `lark-cli auth list` is a read-only probe and must not fail-hard when no
|
||||
// config exists yet — scripts and AI agents use it as an idempotent "do I
|
||||
// have any users?" check, so the exit code carries semantic weight. Pair
|
||||
// that with the existing "configured but no logged-in users" branch (also
|
||||
// exit 0) and both empty states are consistent.
|
||||
func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
// Local workspace → hint must mention init, not bind.
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config init") {
|
||||
t.Errorf("local hint missing config init: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
// `config bind --help` instead of the local-only `config init`.
|
||||
func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
prev := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(prev) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceOpenClaw)
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should still succeed under agent workspace; got: %v", err)
|
||||
}
|
||||
out := stderr.String()
|
||||
if !strings.Contains(out, "config bind --help") {
|
||||
t.Errorf("agent hint must point at config bind --help: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "config init") {
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,9 @@ For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, user login is not allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode)
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
@@ -243,7 +242,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -251,6 +254,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_uri_complete": authResp.VerificationUriComplete,
|
||||
"user_code": authResp.UserCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"agent_hint": msg.AgentTimeoutHint,
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -260,6 +264,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -346,9 +351,15 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
|
||||
@@ -22,6 +22,7 @@ type loginMsg struct {
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AgentTimeoutHint string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
AuthorizedUser string
|
||||
@@ -58,6 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code,导致用户授权的链接失效。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -93,6 +95,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is ≥ 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -94,3 +95,21 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
|
||||
// auth-login output tells AI agents two things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, and (b) the alternative is the
|
||||
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
|
||||
// kills the process before the user can authorize; without (b) the AI has no
|
||||
// recovery path and just retries with the same short timeout, invalidating
|
||||
// each new device code in turn.
|
||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||
for _, want := range []string{"--no-wait", "--device-code"} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
@@ -200,9 +200,6 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
}
|
||||
if loginSucceeded {
|
||||
return nil
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
@@ -371,8 +372,12 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -410,8 +415,12 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
@@ -616,8 +625,12 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
|
||||
@@ -62,11 +62,32 @@ func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.
|
||||
Short: "Bind Agent config to a workspace (source / app-id / force)",
|
||||
Long: `Bind an AI Agent's (OpenClaw / Hermes) Feishu credentials to a lark-cli workspace.
|
||||
|
||||
For AI agents: pass --source and --app-id to bind non-interactively.
|
||||
Credentials are synced once; subsequent calls in the Agent's process
|
||||
context automatically use the bound workspace.`,
|
||||
Example: ` lark-cli config bind --source openclaw --app-id <id>
|
||||
lark-cli config bind --source hermes`,
|
||||
--source is auto-detected from env (OPENCLAW_HOME / HERMES_HOME); pass it only to override.
|
||||
|
||||
For AI agents — DO NOT bind without user confirmation. Binding may
|
||||
overwrite an existing one and locks in an identity policy. Ask the user:
|
||||
|
||||
--identity bot-only bot only (safer default; no impersonation;
|
||||
cannot access user resources like personal
|
||||
calendar / mail / drive)
|
||||
--identity user-default user identity allowed (impersonates the user;
|
||||
needed for personal-resource access)
|
||||
|
||||
Default to bot-only if the user is unsure. Only run the command after
|
||||
the user confirms both intent and identity preset.
|
||||
|
||||
If lark-cli is already bound and the user only wants to change identity
|
||||
policy on the SAME app, use 'config strict-mode' — that's the policy
|
||||
switch and does not require re-bind. Use 'config bind' only when the
|
||||
underlying app itself changes.
|
||||
|
||||
Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
Example: ` # AI flow: confirm intent + identity with user FIRST, then run:
|
||||
lark-cli config bind --source openclaw --app-id <id> --identity bot-only
|
||||
lark-cli config bind --source hermes --identity user-default
|
||||
|
||||
# Interactive (terminal user) — TUI prompts for everything:
|
||||
lark-cli config bind`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if runF != nil {
|
||||
@@ -125,6 +146,7 @@ func configBindRun(opts *BindOptions) error {
|
||||
return err
|
||||
}
|
||||
applyPreferences(appConfig, opts)
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
}
|
||||
@@ -308,6 +330,23 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||
// flag-mode bind that lands on user-default. The bot-only → user-default
|
||||
// escalation is already covered by warnIdentityEscalation (errors out before
|
||||
// applyPreferences runs), and the TUI flow shows IdentityUserDefaultDesc
|
||||
// during identity selection — so this fires specifically for the case those
|
||||
// two miss: a fresh flag-mode bind that goes directly to user-default with
|
||||
// no previous bot lock to escalate from. Without this, AI agents finish such
|
||||
// a bind with only a "配置成功" message and never relay to the user that the
|
||||
// AI can now act under their identity.
|
||||
func noticeUserDefaultRisk(opts *BindOptions) {
|
||||
if opts.IsTUI || opts.Identity != "user-default" {
|
||||
return
|
||||
}
|
||||
msg := getBindMsg(opts.Lang)
|
||||
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
// applyPreferences expands the chosen identity preset into the underlying
|
||||
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
|
||||
// profile's intent survives later changes to global strict-mode settings.
|
||||
|
||||
@@ -377,16 +377,28 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unbound workspace")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
// Should be a structured ConfigError suggesting config bind, not config init.
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
|
||||
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
|
||||
}
|
||||
// Hint must point at config bind --help (NOT a ready-to-run bind command):
|
||||
// AI must read the help and confirm identity preset with the user first.
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point at `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") {
|
||||
t.Errorf("agent hint must not mention config init; got %q", cfgErr.Hint)
|
||||
}
|
||||
// Should suggest config bind, not config init
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Message: "openclaw context detected but lark-cli not bound to openclaw workspace",
|
||||
Hint: "run: lark-cli config bind --source openclaw",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helper function tests (dotenv, brand, path resolution) ──
|
||||
|
||||
62
cmd/config/bind_warning_test.go
Normal file
62
cmd/config/bind_warning_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
|
||||
// with the given identity preset in flag (non-TUI) mode, and returns captured
|
||||
// stderr. Hermes is the simplest source to fake (single .env file).
|
||||
func runHermesBindWithIdentity(t *testing.T, identity string) string {
|
||||
t.Helper()
|
||||
saveWorkspace(t)
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
hermesHome := t.TempDir()
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"
|
||||
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(envContent), 0600); err != nil {
|
||||
t.Fatalf("write .env: %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{
|
||||
Factory: f,
|
||||
Source: "hermes",
|
||||
Identity: identity,
|
||||
Lang: "zh",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bind failed: %v", err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation covers the
|
||||
// gap that previously slipped through: a fresh flag-mode bind landing on
|
||||
// user-default. warnIdentityEscalation requires a previous bot lock to fire,
|
||||
// and IdentityUserDefaultDesc only renders in TUI selection — so without
|
||||
// noticeUserDefaultRisk the user/AI never see the impersonation risk on a
|
||||
// first-time user-default bind.
|
||||
func TestConfigBindRun_UserDefaultIdentity_WarnsAboutImpersonation(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "user-default")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user-default bind must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
|
||||
out := runHermesBindWithIdentity(t, "bot-only")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -90,15 +90,15 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if cfgErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
|
||||
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
@@ -33,6 +34,13 @@ type ConfigInitOptions struct {
|
||||
Lang string
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
// users with a legitimate need for a separate app can pass --force-init
|
||||
// to bypass.
|
||||
ForceInit bool
|
||||
}
|
||||
|
||||
// NewCmdConfigInit creates the config init subcommand.
|
||||
@@ -46,10 +54,18 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
|
||||
For AI agents: use --new to create a new app. The command blocks until the user
|
||||
completes setup in the browser. Run it in the background and retrieve the
|
||||
verification URL from its output.`,
|
||||
verification URL from its output.
|
||||
|
||||
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
|
||||
refuses by default — use 'lark-cli config bind' to bind to the Agent's
|
||||
existing app instead of creating a parallel one. Pass --force-init only
|
||||
if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
if err := guardAgentWorkspace(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -63,10 +79,33 @@ verification URL from its output.`,
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
|
||||
// Hermes Agent context, because the Agent has already provisioned an app
|
||||
// and 'config bind' is the right tool for hooking lark-cli into it.
|
||||
// Running init here would create a parallel app under the agent's workspace
|
||||
// dir, breaking the binding the user actually wants. --force-init lets a
|
||||
// human user override when they really do want a separate app.
|
||||
func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if opts.ForceInit {
|
||||
return nil
|
||||
}
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
return &core.ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
|
||||
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin
|
||||
|
||||
69
cmd/config/init_guard_test.go
Normal file
69
cmd/config/init_guard_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
t.Setenv("OPENCLAW_CLI", "")
|
||||
t.Setenv("HERMES_HOME", "")
|
||||
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{}); err != nil {
|
||||
t.Errorf("local workspace should allow init, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in OpenClaw context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "--force-init") {
|
||||
t.Errorf("hint must mention --force-init escape hatch; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
|
||||
t.Setenv("HERMES_HOME", t.TempDir())
|
||||
|
||||
err := guardAgentWorkspace(&ConfigInitOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected refusal in Hermes context, got nil")
|
||||
}
|
||||
var cfgErr *core.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardAgentWorkspace_ForceInitOverride(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", t.TempDir())
|
||||
|
||||
// --force-init must let the user proceed even inside an Agent context.
|
||||
if err := guardAgentWorkspace(&ConfigInitOptions{ForceInit: true}); err != nil {
|
||||
t.Errorf("--force-init should bypass the guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,12 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return notConfiguredError()
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
@@ -75,18 +75,3 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// notConfiguredError returns the "not configured" error with a hint that
|
||||
// points the user to the right next step: config init for the default local
|
||||
// workspace, config bind for an Agent workspace that has not been bound yet.
|
||||
func notConfiguredError() error {
|
||||
ws := core.CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return output.ErrWithHint(output.ExitValidation, "config",
|
||||
"not configured",
|
||||
"run: lark-cli config init")
|
||||
}
|
||||
return output.ErrWithHint(output.ExitValidation, ws.Display(),
|
||||
fmt.Sprintf("%s context detected but lark-cli not bound to %s workspace", ws.Display(), ws.Display()),
|
||||
fmt.Sprintf("run: lark-cli config bind --source %s", ws.Display()))
|
||||
}
|
||||
|
||||
@@ -21,44 +21,44 @@ func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "strict-mode [bot|user|off]",
|
||||
Short: "View or set strict mode (identity restriction policy)",
|
||||
Long: `View or set strict mode (identity restriction policy).
|
||||
Long: `View or set strict mode — the identity restriction policy.
|
||||
|
||||
Without arguments, shows the current strict mode status and its source.
|
||||
Pass "bot", "user", or "off" to set strict mode.
|
||||
Use --global to set at the global level.
|
||||
Use --reset to clear the profile-level setting (inherit global).
|
||||
bot only bot identity allowed (user commands hidden)
|
||||
user only user identity allowed (bot commands hidden)
|
||||
off no restriction (default)
|
||||
|
||||
Modes:
|
||||
bot — only bot identity is allowed, user commands are hidden
|
||||
user — only user identity is allowed, bot commands are hidden
|
||||
off — no restriction (default)
|
||||
No args: show current mode. Switching does NOT require re-bind.
|
||||
|
||||
WARNING: Strict mode is a security policy set by the administrator.
|
||||
AI agents are strictly prohibited from modifying this setting.`,
|
||||
For AI agents: this is a security policy. DO NOT switch without
|
||||
explicit user confirmation — never run on your own initiative.`,
|
||||
Example: ` lark-cli config strict-mode # show current
|
||||
lark-cli config strict-mode user # switch (after user confirms)
|
||||
lark-cli config strict-mode bot --global # set globally
|
||||
lark-cli config strict-mode --reset # clear profile override`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
if reset {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return resetStrictMode(f, multi, app, global, args)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return showStrictMode(cmd.Context(), f, multi, app)
|
||||
}
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if !global && app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
return setStrictMode(f, multi, app, args[0], global)
|
||||
},
|
||||
@@ -106,6 +106,24 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||
// only when the policy actually expands user-identity at that scope.
|
||||
// --global → compare raw multi.StrictMode (profiles with explicit
|
||||
// overrides are unaffected; their warning comes from the existing
|
||||
// "profile %q has strict-mode explicitly set" notice below).
|
||||
// profile → compare effective mode (override > global > default), so
|
||||
// a profile flipping from inherited bot to explicit off still warns.
|
||||
// The previous version always used the profile's effective mode, which
|
||||
// false-positived (--global change while current profile has an explicit
|
||||
// override) and false-negatived (--global broadening that doesn't affect
|
||||
// the current profile but does affect other inheriting profiles).
|
||||
var oldMode core.StrictMode
|
||||
if global {
|
||||
oldMode = multi.StrictMode
|
||||
} else {
|
||||
oldMode, _ = resolveStrictModeStatus(multi, app)
|
||||
}
|
||||
|
||||
if global {
|
||||
multi.StrictMode = mode
|
||||
for _, a := range multi.Apps {
|
||||
@@ -119,7 +137,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
}
|
||||
} else {
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
|
||||
return core.NoActiveProfileError()
|
||||
}
|
||||
app.StrictMode = &mode
|
||||
}
|
||||
@@ -127,6 +145,11 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
|
||||
}
|
||||
|
||||
scope := "profile"
|
||||
if global {
|
||||
scope = "global"
|
||||
@@ -135,6 +158,16 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeRelaxLang picks the bind-message bundle whose language matches the
|
||||
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
|
||||
// available (global mutation with no current app).
|
||||
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
|
||||
if app != nil {
|
||||
return getBindMsg(app.Lang)
|
||||
}
|
||||
return getBindMsg("")
|
||||
}
|
||||
|
||||
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
||||
if app != nil && app.StrictMode != nil {
|
||||
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
||||
|
||||
140
cmd/config/strict_mode_warning_test.go
Normal file
140
cmd/config/strict_mode_warning_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// runStrictMode is a small helper that runs `config strict-mode <args...>` and
|
||||
// returns the captured stderr — that's where success-path messages and the
|
||||
// new user-identity warning land.
|
||||
func runStrictMode(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
|
||||
cmd := NewCmdConfigStrictMode(f)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("strict-mode %v failed: %v", args, err)
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
// expandsUserIdentity covers the only two transitions where AI gains the
|
||||
// ability to act under the user's identity, and asserts the warning fires.
|
||||
// Reuses bind_messages.go's IdentityEscalationMessage as the canonical text
|
||||
// so all three call sites (bind upgrade, fresh user-default bind, strict-mode
|
||||
// relax) stay phrased identically.
|
||||
func TestStrictMode_BotToUser_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "user")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→user transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_BotToOff_WarnsAboutIdentityRisk(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot")
|
||||
|
||||
out := runStrictMode(t, "off")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("bot→off transition must surface IdentityEscalationMessage; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// narrowingDoesNotWarn covers the cases that revoke or keep user-identity
|
||||
// scope — those should stay quiet, otherwise AI will spam users with risk
|
||||
// text on every restrictive change.
|
||||
func TestStrictMode_UserToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "user")
|
||||
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("user→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToBot_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
// Default starts at off; explicitly set bot — narrowing.
|
||||
out := runStrictMode(t, "bot")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→bot is a narrowing change; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_OffToUser_NoWarning(t *testing.T) {
|
||||
// Off already permits user-identity, so off→user is not a NEW grant
|
||||
// even though it forces user identity. Don't warn.
|
||||
setupStrictModeTestConfig(t)
|
||||
out := runStrictMode(t, "user")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("off→user does not newly permit user identity; must not warn. got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- --global path: comparison must use multi.StrictMode, not profile's
|
||||
// effective mode. The previous (buggy) version used resolveStrictModeStatus
|
||||
// here too, leading to both false positives (current profile has explicit
|
||||
// override unaffected by --global → still warned) and false negatives
|
||||
// (current profile has explicit override that masks an actual bot → off
|
||||
// global broadening for OTHER inheriting profiles → didn't warn).
|
||||
|
||||
func TestStrictMode_GlobalBotToUser_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→user must warn (broadens user-identity for inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictMode_GlobalBotToOff_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global")
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn (newly permits user identity in inheriting profiles); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalsePositive: current profile has explicit "bot" override, global goes
|
||||
// off → user. The current profile is unaffected (still bot via override),
|
||||
// and off→user at the global level is not a new grant either. Must not warn.
|
||||
func TestStrictMode_GlobalOffToUser_WithProfileBotOverride_NoWarning(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot") // profile-level explicit bot
|
||||
runStrictMode(t, "off", "--global") // global = off
|
||||
|
||||
out := runStrictMode(t, "user", "--global")
|
||||
if strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global off→user with profile-bot-override must not warn (profile unaffected, global wasn't bot); got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// FalseNegative: global = bot, current profile has explicit "off" override.
|
||||
// Running --global off broadens OTHER inheriting profiles (bot → off). The
|
||||
// current profile doesn't change effective mode, but the policy still expanded
|
||||
// user-identity, so warning must fire. The pre-fix logic compared via the
|
||||
// current profile's effective mode and missed this case.
|
||||
func TestStrictMode_GlobalBotToOff_WithProfileOffOverride_Warns(t *testing.T) {
|
||||
setupStrictModeTestConfig(t)
|
||||
runStrictMode(t, "bot", "--global") // global = bot
|
||||
runStrictMode(t, "off") // profile-level explicit off (already shows the warning at profile scope)
|
||||
|
||||
out := runStrictMode(t, "off", "--global")
|
||||
if !strings.Contains(out, bindMsgZh.IdentityEscalationMessage) {
|
||||
t.Errorf("global bot→off must warn even when current profile has explicit off (other profiles inherit and newly permit user identity); got: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -83,7 +84,20 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init"))
|
||||
// For "config not present" cases, prefer the workspace-aware
|
||||
// NotConfiguredError message + hint (e.g. "openclaw context
|
||||
// detected but lark-cli is not bound to it" → bind --help) over
|
||||
// the OS-level "open ... no such file or directory".
|
||||
// For other errors (parse, perms), keep the raw error so the
|
||||
// underlying problem is still visible.
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("config_file", msg, hint))
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("config_file", "config.json found"))
|
||||
|
||||
@@ -32,9 +32,9 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(name)
|
||||
|
||||
@@ -32,9 +32,9 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
idx := multi.FindAppIndex(oldName)
|
||||
|
||||
@@ -31,9 +31,9 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
|
||||
func profileUseRun(f *cmdutil.Factory, name string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle "-" for toggle-back
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -48,10 +49,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,11 +343,15 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
|
||||
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
||||
})
|
||||
|
||||
// auth login is user-only, so it gets pruned in strict-mode-bot and the
|
||||
// stub error fires (not login.go's inline check, which is shadowed by
|
||||
// pruning).
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -364,7 +368,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -401,7 +406,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -419,7 +425,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -436,7 +443,8 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -454,7 +462,8 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,8 +142,12 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
if interval < 1 {
|
||||
interval = 5
|
||||
}
|
||||
|
||||
const maxPollInterval = 60
|
||||
const maxPollAttempts = 200
|
||||
const maxPollAttempts = 600
|
||||
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
@@ -5,10 +5,12 @@ package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +19,12 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
// TestResolveOAuthEndpoints_Feishu validates endpoints for the Feishu brand.
|
||||
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveOAuthEndpoints(core.BrandFeishu)
|
||||
@@ -172,3 +180,33 @@ func TestLogAuthError_RecordsStructuredEntry(t *testing.T) {
|
||||
t.Fatalf("expected truncated cmdline in log, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requests atomic.Int32
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requests.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
|
||||
if result == nil {
|
||||
t.Fatal("PollDeviceToken() returned nil result")
|
||||
}
|
||||
if result.Message != "Polling was cancelled" {
|
||||
t.Fatalf("PollDeviceToken() message = %q, want polling cancellation", result.Message)
|
||||
}
|
||||
if got := requests.Load(); got != 0 {
|
||||
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +160,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
|
||||
mode := f.ResolveStrictMode(ctx)
|
||||
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
||||
return output.Errorf(output.ExitValidation, "strict_mode",
|
||||
"strict mode is %q, only %s identity is allowed. "+
|
||||
"This setting is managed by the administrator and must not be modified by AI agents.",
|
||||
mode, mode.ForcedIdentity())
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
|
||||
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
|
||||
raw, err := LoadMultiAppConfig()
|
||||
if err != nil || raw == nil || len(raw.Apps) == 0 {
|
||||
return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
return ResolveConfigFromMulti(raw, kc, profileOverride)
|
||||
}
|
||||
|
||||
120
internal/core/notconfigured.go
Normal file
120
internal/core/notconfigured.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LoadOrNotConfigured wraps LoadMultiAppConfig with the standard "not yet
|
||||
// configured vs. couldn't read" disambiguation that every config-required
|
||||
// command should use:
|
||||
//
|
||||
// - file missing → workspace-aware NotConfiguredError (init / bind hint)
|
||||
// - parse error / permission error → real load failure with the original
|
||||
// cause preserved, so the user can actually fix the broken file
|
||||
//
|
||||
// Without this, every call site that did `if err != nil { return
|
||||
// NotConfiguredError() }` silently coerced corrupt-config into "run init",
|
||||
// which sent users in circles when their config.json was just malformed.
|
||||
func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
multi, err := LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// Surface the real cause (parse error, permission denied, etc.)
|
||||
// so the user can fix the broken file. Wrapping as ConfigError
|
||||
// keeps it on the standard structured-envelope path at the root
|
||||
// command's error sink.
|
||||
return nil, &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: fmt.Sprintf("failed to load config: %v", err),
|
||||
}
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
return multi, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// localInitHint is the canonical "you're in a regular terminal, run
|
||||
// init" guidance — shared by NotConfiguredError and NoActiveProfileError
|
||||
// so the same session can't show two different recommended commands.
|
||||
localInitHint = "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."
|
||||
|
||||
// agentBindHint is the canonical "you're in an Agent workspace, see
|
||||
// the binding workflow" guidance. Always points at --help (never a
|
||||
// ready-to-run bind command) so the AI reads the confirmation
|
||||
// discipline (identity preset, user opt-in) before acting.
|
||||
agentBindHint = "read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`"
|
||||
)
|
||||
|
||||
// NotConfiguredError returns the canonical "not configured" error, with a
|
||||
// hint that depends on the active workspace:
|
||||
//
|
||||
// - WorkspaceLocal → suggest `config init --new` (creates a new app).
|
||||
// - WorkspaceOpenClaw / WorkspaceHermes → point at `config bind --help`
|
||||
// rather than a ready-to-run command, because binding is policy-laden:
|
||||
// the user must pick an identity preset (bot-only vs user-default),
|
||||
// and re-binding may overwrite an existing one. The help text walks
|
||||
// the AI through the confirmation flow.
|
||||
//
|
||||
// All "config not loaded yet" call sites should use this helper rather than
|
||||
// hand-rolling a hint, so AI agents always get a workspace-correct next step.
|
||||
func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: "not configured",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("%s context detected but lark-cli is not bound to it", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
}
|
||||
}
|
||||
|
||||
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
|
||||
// used by error paths that aren't full ConfigErrors (e.g. plain fmt.Errorf
|
||||
// strings from keychain / secret validation). Local → `config init`;
|
||||
// Agent → `config bind --help` so the AI reads the binding workflow and
|
||||
// confirms identity preset with the user before running the actual command.
|
||||
func reconfigureHint() string {
|
||||
if CurrentWorkspace().IsLocal() {
|
||||
return "please run `lark-cli config init` to reconfigure"
|
||||
}
|
||||
return agentBindHint
|
||||
}
|
||||
|
||||
// NoActiveProfileError mirrors NotConfiguredError for the related
|
||||
// "config exists but the requested profile cannot be resolved" case. In agent
|
||||
// workspaces a missing profile typically means the binding was wiped while
|
||||
// the workspace marker remained — re-binding is the correct fix, not init.
|
||||
func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: "config",
|
||||
Message: "no active profile",
|
||||
Hint: localInitHint,
|
||||
}
|
||||
}
|
||||
return &ConfigError{
|
||||
Code: 2,
|
||||
Type: ws.Display(),
|
||||
Message: fmt.Sprintf("no active profile in %s workspace", ws.Display()),
|
||||
Hint: agentBindHint,
|
||||
}
|
||||
}
|
||||
181
internal/core/notconfigured_test.go
Normal file
181
internal/core/notconfigured_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// saveAndRestoreWorkspace ensures package-level currentWorkspace is reset
|
||||
// between subtests so cross-test pollution can't make assertions pass by
|
||||
// accident.
|
||||
func saveAndRestoreWorkspace(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := CurrentWorkspace()
|
||||
t.Cleanup(func() { SetCurrentWorkspace(prev) })
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
||||
t.Errorf("unexpected detail: %+v", cfgErr)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("local hint should suggest config init --new; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config bind") {
|
||||
t.Errorf("local hint must not mention config bind; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_OpenClaw(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "openclaw" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
|
||||
}
|
||||
// Hint must point at --help (read first, confirm with user, then bind),
|
||||
// NOT a directly-executable bind command — binding is policy-laden
|
||||
// (identity preset, may overwrite existing binding).
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") {
|
||||
t.Errorf("agent hint must NOT mention config init (would cause AI to create a new app); got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredError_Hermes(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
err := NotConfiguredError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Type != "hermes" {
|
||||
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("hermes hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveProfileError_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "no active profile" {
|
||||
t.Errorf("message = %q, want %q", cfgErr.Message, "no active profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveProfileError_AgentSuggestsBind(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceOpenClaw)
|
||||
|
||||
err := NoActiveProfileError()
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config bind --help") {
|
||||
t.Errorf("agent hint must point to `config bind --help`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureHint_Local(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
|
||||
got := reconfigureHint()
|
||||
if !strings.Contains(got, "config init") {
|
||||
t.Errorf("local reconfigure hint must mention config init; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconfigureHint_Agent(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceHermes)
|
||||
|
||||
got := reconfigureHint()
|
||||
if !strings.Contains(got, "config bind --help") {
|
||||
t.Errorf("agent reconfigure hint must point to `config bind --help`; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrNotConfigured_FileMissing_ReturnsNotConfigured(t *testing.T) {
|
||||
saveAndRestoreWorkspace(t)
|
||||
SetCurrentWorkspace(WorkspaceLocal)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
_, err := LoadOrNotConfigured()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if cfgErr.Message != "not configured" {
|
||||
t.Errorf("message = %q, want \"not configured\"", cfgErr.Message)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "config init --new") {
|
||||
t.Errorf("missing-file in local must hint `config init --new`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrNotConfigured_CorruptFile_PreservesCause is the regression guard
|
||||
// for the previous "every load error → not configured" coercion: a malformed
|
||||
// config.json must surface its real failure cause so the user can fix it,
|
||||
// not get sent in circles by an init/bind hint that wouldn't help here.
|
||||
func TestLoadOrNotConfigured_CorruptFile_PreservesCause(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Write garbage that will fail JSON parsing.
|
||||
if err := os.WriteFile(dir+"/config.json", []byte("{not valid json"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := LoadOrNotConfigured()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt config")
|
||||
}
|
||||
var cfgErr *ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error type = %T, want *ConfigError", err)
|
||||
}
|
||||
if !strings.Contains(cfgErr.Message, "failed to load config") {
|
||||
t.Errorf("corrupt-file message must say 'failed to load config'; got %q", cfgErr.Message)
|
||||
}
|
||||
// And it must NOT pretend the user just hasn't initialised yet.
|
||||
if cfgErr.Message == "not configured" {
|
||||
t.Errorf("corrupt-file must not be coerced to 'not configured'")
|
||||
}
|
||||
if strings.Contains(cfgErr.Hint, "config init") || strings.Contains(cfgErr.Hint, "config bind") {
|
||||
t.Errorf("corrupt-file hint must not redirect to init/bind; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +63,8 @@ func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
|
||||
expected := secretAccountKey(appId)
|
||||
if secret.Ref.ID != expected {
|
||||
return fmt.Errorf(
|
||||
"appSecret keychain key %q does not match appId %q (expected %q); "+
|
||||
"please run `lark-cli config init` to reconfigure",
|
||||
secret.Ref.ID, appId, expected,
|
||||
"appSecret keychain key %q does not match appId %q (expected %q); %s",
|
||||
secret.Ref.ID, appId, expected, reconfigureHint(),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -203,7 +203,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no credential provider returned an account; run 'lark-cli config' to set up")
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
|
||||
@@ -36,7 +36,7 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
// Load config once — used for both credentials and strict mode.
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
|
||||
@@ -10,7 +10,7 @@ const (
|
||||
ExitOK = 0 // 成功
|
||||
ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit)
|
||||
ExitValidation = 2 // 参数校验失败
|
||||
ExitAuth = 3 // 认证失败(token 无效 / 过期)
|
||||
ExitAuth = 3 // 认证失败(token 无效 / 过期),或登录成功但请求 scopes 未全部授予
|
||||
ExitNetwork = 4 // 网络错误(连接超时、DNS 解析失败等)
|
||||
ExitInternal = 5 // 内部错误(不应发生)
|
||||
ExitContentSafety = 6 // content safety violation (block mode)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -112,11 +112,43 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
nil,
|
||||
map[string]int{"max-version": 11, "page-size": 30},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordUpsert(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1")
|
||||
assertDryRunContains(t, dryRunRecordHistoryList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/record_history", "max_version=11", "page_size=30", "record_id=rec_1", "table_id=tbl_1")
|
||||
|
||||
getSingleRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_1"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_1"]`)
|
||||
|
||||
getSingleFieldsRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_1"}, "field-id": {"Name", "Age"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getSingleFieldsRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`, `"select_fields":["Name","Age"]`)
|
||||
|
||||
getBatchRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"record-id": {"rec_2", "rec_1"}, "field-id": {"Name", "Age"}},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_2","rec_1"]`, `"select_fields":["Name","Age"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_2","rec_1"]`)
|
||||
|
||||
getJSONRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"record_id_list":["rec_3"],"select_fields":["Status"]}`},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`)
|
||||
assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`)
|
||||
|
||||
uploadAttachmentRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
|
||||
@@ -1054,42 +1054,322 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"records": map[string]interface{}{
|
||||
"schema": []interface{}{"Name", "Age"},
|
||||
"record_ids": []interface{}{"rec_1"},
|
||||
"rows": []interface{}{[]interface{}{"Alice", 18}},
|
||||
}},
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) {
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"`_record_id` is metadata for record operations, not a table field.",
|
||||
"- `_record_id`: rec_1",
|
||||
"- `Name`: Alice",
|
||||
"- `Age`: 18",
|
||||
"Meta: count=1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Age"`) || strings.Contains(got, `"record":`) || strings.Contains(got, `"raw"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get passthrough fallback", func(t *testing.T) {
|
||||
t.Run("get with selected fields", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2",
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"},
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil {
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Name"`) || !strings.Contains(got, `"Age"`) || !strings.Contains(got, `"Alice"`) || strings.Contains(got, `"record":`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with repeated record-id flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"| _record_id | Name |",
|
||||
"| rec_2 | Bob |",
|
||||
"| rec_1 | Alice |",
|
||||
"Meta: count=2",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) || !strings.Contains(got, `"Bob"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with json selector", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_3"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{"Carol"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"],"select_fields":["Name"]}`, "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_3"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get single returns batch_get error when batch_get is unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_get error")
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get single missing record renders not found markdown", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_missing"},
|
||||
"fields": []interface{}{"Name"},
|
||||
"data": []interface{}{[]interface{}{nil}},
|
||||
"has_more": false,
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_missing"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Record not found.",
|
||||
"- `_record_id`: rec_missing",
|
||||
"Meta: count=1; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "- `Name`:") {
|
||||
t.Fatalf("missing record output should not render business fields:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch returns batch_get error when batch_get is unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_get error")
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get batch with json record ids and field flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_4"},
|
||||
"fields": []interface{}{"Status"},
|
||||
"data": []interface{}{[]interface{}{"Done"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_4"]}`, "--field-id", "Status", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"Done"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_4"]`) || !strings.Contains(body, `"select_fields":["Status"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects duplicate record ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects duplicate field ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Name"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate field id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects mixed record-id and json", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects mixed field-id and json select_fields", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_2"],"select_fields":["Name"]}`, "--field-id", "Age"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "select_fields") || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get rejects empty selection", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
@@ -1189,17 +1469,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
||||
})
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || strings.Contains(got, `"deleted": true`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete returns batch_delete error when unavailable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Status: 404,
|
||||
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected batch_delete error")
|
||||
}
|
||||
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
||||
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete batch with repeated record-id flags", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete batch with json selector", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"]}`, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_3"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(batchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_3"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete requires yes for batch", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete rejects duplicate record ids", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1", "--yes"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete rejects mixed record-id and json", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`, "--yes"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment", func(t *testing.T) {
|
||||
|
||||
@@ -259,10 +259,15 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
name: "record get",
|
||||
shortcut: BaseRecordGet,
|
||||
wantHelp: []string{
|
||||
"record ID",
|
||||
"record ID (repeatable)",
|
||||
"field ID or name to project; repeat to keep only needed columns",
|
||||
"output format: markdown (default) | json",
|
||||
},
|
||||
wantTips: []string{
|
||||
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
|
||||
"Default output is markdown",
|
||||
"projection boundary",
|
||||
"record_id is already known",
|
||||
"lark-base record read SOP",
|
||||
},
|
||||
@@ -294,6 +299,39 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"complete field definition JSON object; update uses full PUT semantics, not a patch",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
"Type conversion is allowlist-based",
|
||||
"web UI",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"lark-base skill's field-update guide",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOrder(t *testing.T, help string, before string, after string) {
|
||||
t.Helper()
|
||||
beforeIndex := strings.Index(help, before)
|
||||
@@ -355,8 +393,8 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
if BaseRecordSearch.Validate == nil {
|
||||
t.Fatalf("record search validate should reject invalid JSON before dry-run")
|
||||
}
|
||||
if BaseRecordGet.Validate != nil {
|
||||
t.Fatalf("record get validate should be nil")
|
||||
if BaseRecordGet.Validate == nil {
|
||||
t.Fatalf("record get validate should reject invalid record selection before dry-run")
|
||||
}
|
||||
if BaseRecordUpsert.Validate == nil {
|
||||
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
|
||||
|
||||
@@ -20,12 +20,16 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
fieldRefFlag(true),
|
||||
{Name: "json", Desc: "field property JSON object", Required: true},
|
||||
{Name: "json", Desc: "complete field definition JSON object; update uses full PUT semantics, not a patch", Required: true},
|
||||
{Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: --json '{"name":"Status","type":"text"}'`,
|
||||
"Agent hint: use the lark-base skill's field-update guide for usage and limits.",
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"text"}'`,
|
||||
`Example: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id <field_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFieldUpdate(runtime)
|
||||
|
||||
@@ -195,6 +195,62 @@ func TestRecordAndChunkHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordSelectionHelpers(t *testing.T) {
|
||||
recordIDs, err := normalizeRecordIDs([]string{" rec_1 ", "rec_2"})
|
||||
if err != nil || !reflect.DeepEqual(recordIDs, []string{"rec_1", "rec_2"}) {
|
||||
t.Fatalf("recordIDs=%v err=%v", recordIDs, err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{}); err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{"rec_1", "rec_1"}); err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{" "}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordIDs([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
tooManyRecords := make([]string, maxRecordSelectionCount+1)
|
||||
if _, err := normalizeRecordIDs(tooManyRecords); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err := normalizeRecordGetSelectFields([]interface{}{" Name ", "fld_status"})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name", "fld_status"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if fields, err := normalizeRecordGetSelectFields(nil); err != nil || fields != nil {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{"Name", "Name"}); err == nil || !strings.Contains(err.Error(), "duplicate field id") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{""}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := normalizeRecordGetSelectFields([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
tooManyFields := make([]string, maxBatchGetSelectFieldCount+1)
|
||||
if _, err := normalizeRecordGetSelectFields(tooManyFields); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestResolveHelpers(t *testing.T) {
|
||||
fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}}
|
||||
tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}
|
||||
|
||||
@@ -12,12 +12,20 @@ import (
|
||||
var BaseRecordDelete = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-delete",
|
||||
Description: "Delete a record by ID",
|
||||
Description: "Delete one or more records by ID",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:record:delete"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), recordRefFlag(true)},
|
||||
DryRun: dryRunRecordDelete,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordDelete,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeRecordDelete(runtime)
|
||||
},
|
||||
|
||||
@@ -13,17 +13,29 @@ import (
|
||||
var BaseRecordGet = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-get",
|
||||
Description: "Get a record by ID",
|
||||
Description: "Get one or more records by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordSelection(runtime)
|
||||
},
|
||||
Tips: []string{
|
||||
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
|
||||
"Example with projection: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
|
||||
"Default output is markdown; pass --format json to get the raw JSON envelope.",
|
||||
"Use --field-id as a projection boundary to avoid loading large cell values into context when they are not needed.",
|
||||
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
|
||||
"Agent hint: follow the lark-base record read SOP for record read routing.",
|
||||
},
|
||||
|
||||
@@ -24,6 +24,10 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordMarkdown)
|
||||
}
|
||||
|
||||
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
|
||||
if runtime.JqExpr != "" {
|
||||
if !runtime.Changed("format") {
|
||||
runtime.Out(data, nil)
|
||||
@@ -31,7 +35,7 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf
|
||||
}
|
||||
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
|
||||
}
|
||||
rendered, err := renderRecordMarkdown(data)
|
||||
rendered, err := renderer(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
|
||||
runtime.Out(data, nil)
|
||||
@@ -48,6 +52,27 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
|
||||
}
|
||||
|
||||
func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
|
||||
fields := stringSliceValue(data["fields"])
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
if len(recordIDs) == 1 && len(rows) == 1 {
|
||||
rowItems, _ := rows[0].([]interface{})
|
||||
if recordMarkedNotFound(data["record_not_found"], recordIDs[0]) {
|
||||
return renderMissingSingleRecordMarkdown(recordIDs[0], data), nil
|
||||
}
|
||||
return renderSingleRecordMarkdown(recordIDs[0], fields, rowItems, data), nil
|
||||
}
|
||||
return renderRecordMarkdown(data)
|
||||
}
|
||||
|
||||
func renderRecordMarkdown(data map[string]interface{}) (string, error) {
|
||||
fields := stringSliceValue(data["fields"])
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
@@ -91,9 +116,68 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) {
|
||||
b.WriteString(ignored)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func renderSingleRecordMarkdown(recordID string, fields []string, rowItems []interface{}, data map[string]interface{}) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n")
|
||||
b.WriteString("- `_record_id`: ")
|
||||
b.WriteString(markdownInlineValue(recordID))
|
||||
b.WriteByte('\n')
|
||||
for i, field := range fields {
|
||||
b.WriteString("- `")
|
||||
b.WriteString(field)
|
||||
b.WriteString("`: ")
|
||||
if i < len(rowItems) {
|
||||
b.WriteString(markdownInlineValue(rowItems[i]))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" {
|
||||
b.WriteString("Ignored fields: ")
|
||||
b.WriteString(ignored)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderMissingSingleRecordMarkdown(recordID string, data map[string]interface{}) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Record not found.\n\n")
|
||||
b.WriteString("- `_record_id`: ")
|
||||
b.WriteString(markdownInlineValue(recordID))
|
||||
b.WriteByte('\n')
|
||||
meta := recordMarkdownMeta(data)
|
||||
if len(meta) > 0 {
|
||||
b.WriteString("\nMeta: ")
|
||||
b.WriteString(strings.Join(meta, "; "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" {
|
||||
b.WriteString("Missing records: ")
|
||||
b.WriteString(missing)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func recordMarkdownMeta(data map[string]interface{}) []string {
|
||||
meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))}
|
||||
if hasMore, ok := data["has_more"]; ok {
|
||||
@@ -109,6 +193,9 @@ func recordMarkdownMeta(data map[string]interface{}) []string {
|
||||
if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 {
|
||||
meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount))
|
||||
}
|
||||
if missingCount := ignoredFieldsCount(data["record_not_found"]); missingCount > 0 {
|
||||
meta = append(meta, fmt.Sprintf("record_not_found=%d", missingCount))
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
@@ -138,6 +225,19 @@ func ignoredFieldsMarkdown(value interface{}) string {
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
|
||||
func recordNotFoundMarkdown(value interface{}) string {
|
||||
return strings.Join(markdownListItems(value), ", ")
|
||||
}
|
||||
|
||||
func recordMarkedNotFound(value interface{}, recordID string) bool {
|
||||
for _, item := range markdownListItems(value) {
|
||||
if item == recordID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markdownListItems(value interface{}) []string {
|
||||
switch v := value.(type) {
|
||||
case []interface{}:
|
||||
|
||||
@@ -83,6 +83,75 @@ func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordGetMarkdownSingleRecordUsesKVLayout(t *testing.T) {
|
||||
got, err := renderRecordGetMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name|Label", "Note"},
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"A|B", "line1\nline2"}},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"- `_record_id`: rec_1",
|
||||
"- `Name|Label`: A|B",
|
||||
"- `Note`: line1\nline2",
|
||||
"Meta: count=1; has_more=false",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordGetMarkdownSingleMissingRecordUsesNotFoundLayout(t *testing.T) {
|
||||
got, err := renderRecordGetMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Note"},
|
||||
"record_id_list": []interface{}{"rec_missing"},
|
||||
"data": []interface{}{[]interface{}{nil, nil}},
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Record not found.",
|
||||
"- `_record_id`: rec_missing",
|
||||
"Meta: count=1; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "- `Name`:") {
|
||||
t.Fatalf("missing record layout should not render business fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownIncludesMissingRecords(t *testing.T) {
|
||||
got, err := renderRecordMarkdown(map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_missing"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{nil}},
|
||||
"record_not_found": []interface{}{"rec_missing"},
|
||||
"has_more": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Meta: count=2; has_more=false; record_not_found=1",
|
||||
"Missing records: rec_missing",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) {
|
||||
ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2)
|
||||
for i := range ignored {
|
||||
|
||||
@@ -7,10 +7,194 @@ import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
|
||||
type recordSelection struct {
|
||||
recordIDs []string
|
||||
selectFields []string
|
||||
fromJSON bool
|
||||
}
|
||||
|
||||
type stringListNormalizeOptions struct {
|
||||
typeError string
|
||||
emptyError string
|
||||
itemName string
|
||||
duplicateName string
|
||||
limitName string
|
||||
max int
|
||||
allowNil bool
|
||||
allowEmpty bool
|
||||
}
|
||||
|
||||
func validateRecordSelection(runtime *common.RuntimeContext) error {
|
||||
_, err := resolveRecordSelection(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
|
||||
recordIDs := runtime.StrArray("record-id")
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
|
||||
}
|
||||
if jsonRaw != "" {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, jsonRaw, "json")
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
recordIDListValue, ok := body["record_id_list"]
|
||||
if !ok {
|
||||
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
recordIDItems, ok := recordIDListValue.([]interface{})
|
||||
if !ok {
|
||||
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordIDs(recordIDItems)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
return recordSelection{
|
||||
recordIDs: normalized,
|
||||
selectFields: selectFields,
|
||||
fromJSON: true,
|
||||
}, nil
|
||||
}
|
||||
normalized, err := normalizeRecordIDs(recordIDs)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
return recordSelection{
|
||||
recordIDs: normalized,
|
||||
selectFields: selectFields,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRecordIDs(values interface{}) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "record selection must be a string array",
|
||||
emptyError: `provide at least one --record-id, or use --json with "record_id_list"`,
|
||||
itemName: "record selection item",
|
||||
duplicateName: "record id",
|
||||
limitName: "record selection",
|
||||
max: maxRecordSelectionCount,
|
||||
})
|
||||
}
|
||||
|
||||
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
|
||||
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
rawJSONFields, ok := body["select_fields"]
|
||||
if !ok {
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "field selection must be a string array",
|
||||
itemName: "field selection item",
|
||||
duplicateName: "field id",
|
||||
limitName: "field selection",
|
||||
max: maxBatchGetSelectFieldCount,
|
||||
allowNil: true,
|
||||
allowEmpty: true,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) {
|
||||
var rawItems []interface{}
|
||||
switch typed := values.(type) {
|
||||
case nil:
|
||||
if opts.allowNil {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
case []interface{}:
|
||||
rawItems = typed
|
||||
case []string:
|
||||
rawItems = make([]interface{}, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
rawItems = append(rawItems, item)
|
||||
}
|
||||
default:
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
}
|
||||
if len(rawItems) == 0 {
|
||||
if opts.allowEmpty {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, common.FlagErrorf(opts.emptyError)
|
||||
}
|
||||
if opts.max > 0 && len(rawItems) > opts.max {
|
||||
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
||||
}
|
||||
seen := make(map[string]int, len(rawItems))
|
||||
result := make([]string, 0, len(rawItems))
|
||||
for index, value := range rawItems {
|
||||
item, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
||||
}
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
||||
}
|
||||
if first, exists := seen[item]; exists {
|
||||
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
||||
}
|
||||
seen[item] = index + 1
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func recordGetBatchBody(selection recordSelection) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"record_id_list": selection.recordIDs,
|
||||
}
|
||||
if len(selection.selectFields) > 0 {
|
||||
body["select_fields"] = selection.selectFields
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
offset := runtime.Int("offset")
|
||||
if offset < 0 {
|
||||
@@ -34,11 +218,15 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
}
|
||||
|
||||
func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_get").
|
||||
Body(recordGetBatchBody(selection)).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -90,11 +278,15 @@ func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext)
|
||||
}
|
||||
|
||||
func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete").
|
||||
Body(map[string]interface{}{"record_id_list": selection.recordIDs}).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("record_id", runtime.Str("record-id"))
|
||||
Set("table_id", baseTableID(runtime))
|
||||
}
|
||||
|
||||
func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -201,10 +393,21 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeRecordGet(runtime *common.RuntimeContext) error {
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_get"), nil, recordGetBatchBody(selection))
|
||||
data, err := handleBaseAPIResult(result, err, "batch get records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("format") == "markdown" {
|
||||
return outputRecordGetMarkdown(runtime, data)
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -281,10 +484,17 @@ func executeRecordBatchUpdate(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func executeRecordDelete(runtime *common.RuntimeContext) error {
|
||||
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil)
|
||||
selection, err := resolveRecordSelection(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"deleted": true, "record_id": runtime.Str("record-id")}, nil)
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_delete"), nil, map[string]interface{}{
|
||||
"record_id_list": selection.recordIDs,
|
||||
})
|
||||
data, err := handleBaseAPIResult(result, err, "batch delete records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
|
||||
@@ -18,6 +18,35 @@ import (
|
||||
|
||||
const defaultLocateDocLimit = 10
|
||||
|
||||
// maxCommentTotalRunes is the cap on the combined character (rune) count
|
||||
// across all `reply_elements[].text` fields in a single
|
||||
// `drive +add-comment` request.
|
||||
//
|
||||
// The open-platform `/open-apis/drive/v1/files/{token}/new_comments`
|
||||
// endpoint returns an opaque `[1069302] Invalid or missing parameters`
|
||||
// when this is exceeded — no indication that length is the cause or
|
||||
// which element is at fault.
|
||||
//
|
||||
// Empirically (probing the live API):
|
||||
//
|
||||
// - 10000 runes in a single text element: OK (10000 ASCII / 30000
|
||||
// bytes for Chinese / 40000 bytes if all '<' — server counts the
|
||||
// raw rune count, not byte width and not the post-escape form)
|
||||
// - 10001 runes in a single text element: [1069302]
|
||||
// - 5000 + 5000 across two elements (total 10000): OK
|
||||
// - 5000 + 5001 across two elements (total 10001): [1069302]
|
||||
//
|
||||
// So the cap is applied to the *total* across all reply_elements, not
|
||||
// per element. Splitting an over-the-cap message into multiple text
|
||||
// elements does NOT help — the server enforces the same limit on the
|
||||
// sum.
|
||||
//
|
||||
// The schema doc currently advertises a 1-1000 character limit, but
|
||||
// the live API accepts up to 10000 runes; the schema is out of date.
|
||||
// If this constant ever needs to track a server-side change, re-probe
|
||||
// with `drive file.comments create_v2` against a fresh docx.
|
||||
const maxCommentTotalRunes = 10000
|
||||
|
||||
type commentDocRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
@@ -604,6 +633,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
replyElements := make([]map[string]interface{}, 0, len(inputs))
|
||||
totalRunes := 0
|
||||
for i, input := range inputs {
|
||||
index := i + 1
|
||||
elementType := strings.TrimSpace(input.Type)
|
||||
@@ -612,9 +642,27 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
|
||||
if strings.TrimSpace(input.Text) == "" {
|
||||
return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index)
|
||||
}
|
||||
if utf8.RuneCountInString(input.Text) > 1000 {
|
||||
return nil, output.ErrValidation("--content element #%d text exceeds 1000 characters", index)
|
||||
// Measure the raw rune count of the user input — that is what
|
||||
// the server actually counts. byte width and post-escape form
|
||||
// don't matter (10000 '<' chars succeed even though they
|
||||
// expand to 40000 bytes when escaped, and 10000 Chinese chars
|
||||
// succeed even though they encode as 30000 UTF-8 bytes).
|
||||
runes := utf8.RuneCountInString(input.Text)
|
||||
totalRunes += runes
|
||||
if totalRunes > maxCommentTotalRunes {
|
||||
return nil, output.ErrWithHint(
|
||||
output.ExitValidation,
|
||||
"text_too_long",
|
||||
fmt.Sprintf("--content reply_elements text totals %d characters at element #%d (this element: %d); the server caps the combined length at %d characters across ALL reply_elements",
|
||||
totalRunes, index, runes, maxCommentTotalRunes),
|
||||
fmt.Sprintf("shorten the comment so the combined text across all reply_elements fits within %d characters. The server enforces this cap on the TOTAL — splitting one long element into multiple smaller text elements does NOT help (they all add up against the same %d-rune budget). Server returns an opaque [1069302] on overflow, so this check is pre-flight; no escape transform changes the count (server reads raw runes).", maxCommentTotalRunes, maxCommentTotalRunes),
|
||||
)
|
||||
}
|
||||
// Escape '<' and '>' so the rendered comment displays them as
|
||||
// literal characters instead of being interpreted as markup
|
||||
// by Lark's comment renderer. This is independent of the
|
||||
// length check — the server sees the escaped form, but
|
||||
// counts characters by the raw input length above.
|
||||
replyElements = append(replyElements, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": escapeCommentText(input.Text),
|
||||
|
||||
@@ -5,11 +5,13 @@ package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
|
||||
@@ -292,6 +294,186 @@ func TestParseCommentReplyElementsEscapesAngleBrackets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentReplyElementsTextLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Cap is 10000 runes total across all reply_elements text fields,
|
||||
// empirically derived from the live API. See the comment on
|
||||
// maxCommentTotalRunes for the probe results.
|
||||
exactCapASCII := strings.Repeat("a", 10000)
|
||||
overCapASCII := strings.Repeat("a", 10001)
|
||||
|
||||
// Chinese chars cost 3 bytes each in UTF-8 but the server counts
|
||||
// runes, not bytes — so the cap is the same 10000 here.
|
||||
exactCapCJK := strings.Repeat("文", 10000)
|
||||
overCapCJK := strings.Repeat("文", 10001)
|
||||
|
||||
// '<' would expand to '<' (4 bytes) under escapeCommentText, but
|
||||
// since the server counts raw runes the cap is still 10000 chars,
|
||||
// not 2500. This pins that distinction.
|
||||
exactCapAngle := strings.Repeat("<", 10000)
|
||||
overCapAngle := strings.Repeat("<", 10001)
|
||||
|
||||
// Two-element split exactly hitting the cap together.
|
||||
splitFiveK := strings.Repeat("a", 5000)
|
||||
splitFiveKPlusOne := strings.Repeat("a", 5001)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
wantHint string // substring of the hint portion; "" means don't check hint
|
||||
wantCount int // expected parsed element count when no error expected
|
||||
}{
|
||||
{
|
||||
name: "single element exactly at 10000 ASCII chars accepted",
|
||||
input: `[{"type":"text","text":"` + exactCapASCII + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "single element at 10001 ASCII chars rejected",
|
||||
input: `[{"type":"text","text":"` + overCapASCII + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
|
||||
},
|
||||
{
|
||||
name: "single element exactly at 10000 chinese chars accepted (server counts runes, not bytes)",
|
||||
input: `[{"type":"text","text":"` + exactCapCJK + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "single element at 10001 chinese chars rejected",
|
||||
input: `[{"type":"text","text":"` + overCapCJK + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
name: "10000 angle brackets accepted (server counts raw runes, not escaped form)",
|
||||
input: `[{"type":"text","text":"` + exactCapAngle + `"}]`,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "10001 angle brackets rejected (escape state irrelevant to cap)",
|
||||
input: `[{"type":"text","text":"` + overCapAngle + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
// Pins the multi-element TOTAL cap: two 5000-char elements
|
||||
// fit together exactly (10000 sum). This is the boundary the
|
||||
// previous PR's "split into multiple elements" advice
|
||||
// implied was a workaround — it's actually only valid if
|
||||
// the sum still fits.
|
||||
name: "two elements totalling exactly 10000 accepted",
|
||||
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveK + `"}]`,
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
// Companion to the above and the headline reason the prior
|
||||
// "split into multiple elements" hint is wrong: 5000+5001
|
||||
// sums to 10001 which the server rejects with the same
|
||||
// opaque [1069302], regardless of how many elements it's
|
||||
// distributed across.
|
||||
name: "two elements totalling 10001 rejected with index pointing at offending element",
|
||||
input: `[{"type":"text","text":"` + splitFiveK + `"},{"type":"text","text":"` + splitFiveKPlusOne + `"}]`,
|
||||
wantErr: "totals 10001 characters at element #2",
|
||||
wantHint: "splitting one long element into multiple smaller text elements does NOT help",
|
||||
},
|
||||
{
|
||||
// Streaming-cap correctness: when an EARLY element by itself
|
||||
// already overshoots, the index reported is that early
|
||||
// element (not the last one in the array).
|
||||
name: "first element over the cap reports index 1",
|
||||
input: `[{"type":"text","text":"` + overCapASCII + `"},{"type":"text","text":"trailing"}]`,
|
||||
wantErr: "totals 10001 characters at element #1",
|
||||
},
|
||||
{
|
||||
// mention_user / link elements don't count toward the
|
||||
// rune cap (their content is ID / URL, not user-visible
|
||||
// running text). Pin that a moderate text plus a mention
|
||||
// stays accepted even though the mention adds bytes.
|
||||
name: "text plus mention_user does not double-count toward cap",
|
||||
input: `[{"type":"text","text":"` + exactCapASCII + `"},{"type":"mention_user","text":"ou_1234567890abcdef"}]`,
|
||||
wantCount: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := parseCommentReplyElements(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil (parsed %d elements)", tt.wantErr, len(got))
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||
}
|
||||
if tt.wantHint != "" {
|
||||
// Hint lives on ExitError.Detail.Hint, not err.Error().
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Errorf("expected hint substring %q, got %q", tt.wantHint, exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != tt.wantCount {
|
||||
t.Fatalf("expected %d reply elements, got %d", tt.wantCount, len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCommentReplyElementsHintForbidsSplitAdvice pins that the
|
||||
// over-cap hint does NOT recommend splitting into multiple text
|
||||
// elements as a workaround. An earlier version of this PR shipped
|
||||
// that advice; live-API probing showed the cap is on the *total* run
|
||||
// of characters across all reply_elements, so splitting doesn't
|
||||
// bypass it. If the hint ever drifts back into recommending a split,
|
||||
// users will be sent down a dead end where their first attempt fails
|
||||
// pre-flight, their "fixed" attempt also fails server-side, and
|
||||
// they're stuck.
|
||||
func TestParseCommentReplyElementsHintForbidsSplitAdvice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseCommentReplyElements(`[{"type":"text","text":"` + strings.Repeat("a", 10001) + `"}]`)
|
||||
if err == nil {
|
||||
t.Fatal("expected over-cap error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected ExitError with Detail, got %T (%v)", err, err)
|
||||
}
|
||||
hint := exitErr.Detail.Hint
|
||||
|
||||
// The hint must explicitly call out that splitting does NOT help.
|
||||
if !strings.Contains(hint, "does NOT help") {
|
||||
t.Errorf("hint must explicitly say splitting does NOT help, got: %q", hint)
|
||||
}
|
||||
// Anti-pattern check: the hint must not phrase any "split into
|
||||
// multiple elements" recommendation as a workaround. Look for the
|
||||
// previous PR's exact phrasing variants.
|
||||
for _, banned := range []string{
|
||||
"split the content across multiple",
|
||||
"split into multiple text elements",
|
||||
"renders them as one contiguous comment",
|
||||
} {
|
||||
if strings.Contains(hint, banned) {
|
||||
t.Errorf("hint must not contain the discredited %q advice, got: %q", banned, hint)
|
||||
}
|
||||
}
|
||||
// And it should reference the actual number so callers know the
|
||||
// budget without having to read the source.
|
||||
if !strings.Contains(hint, "10000") {
|
||||
t.Errorf("hint should name the 10000-rune budget, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommentReplyElementsInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -489,6 +489,9 @@ func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (st
|
||||
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
fmt.Fprintf(w, "url: %s\n", url)
|
||||
}
|
||||
version := common.GetString(data, "version")
|
||||
if version == "" {
|
||||
version = common.GetString(data, "data_version")
|
||||
|
||||
@@ -79,6 +79,9 @@ var MarkdownCreate = common.Shortcut{
|
||||
"file_name": finalMarkdownFileName(spec),
|
||||
"size_bytes": fileSize,
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", result.FileToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
@@ -467,6 +467,9 @@ func TestMarkdownCreateSuccessUploadAll(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), `"file_name": "README.md"`) {
|
||||
t.Fatalf("stdout missing file_name: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"url": "https://www.feishu.cn/file/box_md_create"`) {
|
||||
t.Fatalf("stdout missing url: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
@@ -497,6 +500,9 @@ func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
if !strings.Contains(out, "file_token: box_md_create_pretty") {
|
||||
t.Fatalf("pretty output missing file_token: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "url: https://www.feishu.cn/file/box_md_create_pretty") {
|
||||
t.Fatalf("pretty output missing url: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "permission_grant.status: skipped") {
|
||||
t.Fatalf("pretty output missing permission_grant.status: %s", out)
|
||||
}
|
||||
|
||||
421
shortcuts/sheets/lark_sheets_cell_data.go
Normal file
421
shortcuts/sheets/lark_sheets_cell_data.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func parseValues2DJSON(raw string) ([][]interface{}, error) {
|
||||
var rows [][]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
|
||||
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
}
|
||||
if rows == nil {
|
||||
return nil, common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
var SheetRead = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+read",
|
||||
Description: "Read spreadsheet cell values",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "read range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
readRange := runtime.Str("range")
|
||||
if readRange == "" && runtime.Str("sheet-id") != "" {
|
||||
readRange = runtime.Str("sheet-id")
|
||||
}
|
||||
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)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
|
||||
|
||||
params := map[string]interface{}{}
|
||||
renderOption := runtime.Str("value-render-option")
|
||||
if renderOption != "" {
|
||||
params["valueRenderOption"] = renderOption
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetWrite = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+write",
|
||||
Description: "Write to spreadsheet cells (overwrite mode)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "write range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "values", Desc: "2D array JSON", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := parseValues2DJSON(runtime.Str("values")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
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)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/values").
|
||||
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
values, err := parseValues2DJSON(runtime.Str("values"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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{}{
|
||||
"range": writeRange,
|
||||
"values": values,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetAppend = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+append",
|
||||
Description: "Append rows to a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "append range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "values", Desc: "2D array JSON", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := parseValues2DJSON(runtime.Str("values")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
appendRange := runtime.Str("range")
|
||||
if appendRange == "" && runtime.Str("sheet-id") != "" {
|
||||
appendRange = runtime.Str("sheet-id")
|
||||
}
|
||||
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}}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
values, err := parseValues2DJSON(runtime.Str("values"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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{}{
|
||||
"range": appendRange,
|
||||
"values": values,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetFind = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+find",
|
||||
Description: "Find cells in a spreadsheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "find", Desc: "search text", Required: true},
|
||||
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 / C2 with --sheet-id)"},
|
||||
{Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"},
|
||||
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"},
|
||||
{Name: "search-by-regex", Type: "bool", Desc: "regex search"},
|
||||
{Name: "include-formulas", Type: "bool", Desc: "search formulas"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": !runtime.Bool("ignore-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find").
|
||||
Body(map[string]interface{}{
|
||||
"find": runtime.Str("find"),
|
||||
"find_condition": findCondition,
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", sheetID).Set("find", runtime.Str("find"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findText := runtime.Str("find")
|
||||
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": !runtime.Bool("ignore-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizePointRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
|
||||
reqData := map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": findText,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID)), nil, reqData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetReplace = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+replace",
|
||||
Description: "Find and replace cell values in a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "find", Desc: "search text or regex pattern", Required: true},
|
||||
{Name: "replacement", Desc: "replacement text", Required: true},
|
||||
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 with --sheet-id)"},
|
||||
{Name: "match-case", Type: "bool", Desc: "case-sensitive search"},
|
||||
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"},
|
||||
{Name: "search-by-regex", Type: "bool", Desc: "use regex search"},
|
||||
{Name: "include-formulas", Type: "bool", Desc: "search in formulas"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": runtime.Bool("match-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace").
|
||||
Body(map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": runtime.Str("find"),
|
||||
"replacement": runtime.Str("replacement"),
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", sheetID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": runtime.Bool("match-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(sheetID),
|
||||
),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": runtime.Str("find"),
|
||||
"replacement": runtime.Str("replacement"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -43,6 +44,10 @@ var SheetWriteImage = common.Shortcut{
|
||||
if err := validateSingleCellRange(runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := validateSheetWriteImageFile(runtime.Str("image"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -71,25 +76,12 @@ var SheetWriteImage = common.Shortcut{
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
// Resolve the target cell range (--range is required).
|
||||
pointRange := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
// Resolve image file.
|
||||
imagePath := runtime.Str("image")
|
||||
safePath, err := validate.SafeInputPath(imagePath)
|
||||
safePath, stat, err := validateSheetWriteImageFile(imagePath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe image path: %s", err)
|
||||
}
|
||||
stat, err := vfs.Stat(safePath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("image file not found: %s", imagePath)
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("image must be a regular file: %s", imagePath)
|
||||
}
|
||||
const maxImageSize int64 = 20 * 1024 * 1024 // 20 MB
|
||||
if stat.Size() > maxImageSize {
|
||||
return output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
return err
|
||||
}
|
||||
|
||||
imageBytes, err := vfs.ReadFile(safePath)
|
||||
@@ -104,8 +96,6 @@ var SheetWriteImage = common.Shortcut{
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Writing image: %s (%d bytes) → %s\n", imageName, stat.Size(), pointRange)
|
||||
|
||||
// The sheets v2 values_image API expects a JSON body with the image
|
||||
// as an inline byte array, not multipart/form-data.
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_image", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"range": pointRange,
|
||||
"image": imageBytes,
|
||||
@@ -118,3 +108,22 @@ var SheetWriteImage = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateSheetWriteImageFile(imagePath string) (string, fs.FileInfo, error) {
|
||||
safePath, err := validate.SafeInputPath(imagePath)
|
||||
if err != nil {
|
||||
return "", nil, output.ErrValidation("unsafe image path: %s", err)
|
||||
}
|
||||
stat, err := vfs.Stat(safePath)
|
||||
if err != nil {
|
||||
return "", nil, output.ErrValidation("image file not found: %s", imagePath)
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return "", nil, output.ErrValidation("image must be a regular file: %s", imagePath)
|
||||
}
|
||||
const maxImageSize int64 = 20 * 1024 * 1024
|
||||
if stat.Size() > maxImageSize {
|
||||
return "", nil, output.ErrValidation("image %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
}
|
||||
return safePath, stat, nil
|
||||
}
|
||||
350
shortcuts/sheets/lark_sheets_cell_style_and_merge.go
Normal file
350
shortcuts/sheets/lark_sheets_cell_style_and_merge.go
Normal file
@@ -0,0 +1,350 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func validateBatchStyleData(raw string) error {
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok || len(arr) == 0 {
|
||||
return common.FlagErrorf("--data must be a non-empty JSON array")
|
||||
}
|
||||
for i, item := range arr {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d] must be an object with ranges and style", i)
|
||||
}
|
||||
rangesRaw, ok := entry["ranges"]
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d].ranges is required", i)
|
||||
}
|
||||
ranges, ok := rangesRaw.([]interface{})
|
||||
if !ok || len(ranges) == 0 {
|
||||
return common.FlagErrorf("--data[%d].ranges must be a non-empty array of strings", i)
|
||||
}
|
||||
for j, r := range ranges {
|
||||
s, ok := r.(string)
|
||||
if !ok || s == "" {
|
||||
return common.FlagErrorf("--data[%d].ranges[%d] must be a non-empty string", i, j)
|
||||
}
|
||||
if _, _, ok := splitSheetRange(s); !ok {
|
||||
return common.FlagErrorf("--data[%d].ranges[%d] %q must include a sheetId! prefix", i, j, s)
|
||||
}
|
||||
}
|
||||
styleRaw, ok := entry["style"]
|
||||
if !ok {
|
||||
return common.FlagErrorf("--data[%d].style is required", i)
|
||||
}
|
||||
if _, ok := styleRaw.(map[string]interface{}); !ok {
|
||||
return common.FlagErrorf("--data[%d].style must be a JSON object", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var SheetSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+set-style",
|
||||
Description: "Set cell style for a range",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
{Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
if _, ok := style.(map[string]interface{}); !ok {
|
||||
return common.FlagErrorf("--style must be a JSON object, got %T", style)
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("style")), &style)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
|
||||
Body(map[string]interface{}{
|
||||
"appendStyle": map[string]interface{}{
|
||||
"range": r,
|
||||
"style": style,
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"appendStyle": map[string]interface{}{
|
||||
"range": r,
|
||||
"style": style,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetBatchSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-set-style",
|
||||
Description: "Batch set cell styles for multiple ranges",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return validateBatchStyleData(runtime.Str("data"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("data")), &data)
|
||||
normalizeBatchStyleRanges(data)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
|
||||
Body(map[string]interface{}{
|
||||
"data": data,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func normalizeBatchStyleRanges(data interface{}) {
|
||||
items, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ranges, ok := entry["ranges"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, r := range ranges {
|
||||
if s, ok := r.(string); ok {
|
||||
ranges[i] = normalizePointRange("", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var SheetMergeCells = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+merge-cells",
|
||||
Description: "Merge cells in a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
{Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells").
|
||||
Body(map[string]interface{}{
|
||||
"range": r,
|
||||
"mergeType": runtime.Str("merge-type"),
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"range": r,
|
||||
"mergeType": runtime.Str("merge-type"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUnmergeCells = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+unmerge-cells",
|
||||
Description: "Unmerge (split) cells in a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells").
|
||||
Body(map[string]interface{}{
|
||||
"range": r,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"range": r,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -7,11 +7,21 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func filterViewBasePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func filterViewItemPath(token, sheetID, filterViewID string) string {
|
||||
return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID))
|
||||
}
|
||||
|
||||
func filterViewConditionBasePath(token, sheetID, filterViewID string) string {
|
||||
return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID))
|
||||
}
|
||||
@@ -20,6 +30,226 @@ func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID strin
|
||||
return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID))
|
||||
}
|
||||
|
||||
func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) {
|
||||
return validateSheetManageToken(runtime)
|
||||
}
|
||||
|
||||
func hasNonEmptyStringFlag(runtime *common.RuntimeContext, name string) bool {
|
||||
return runtime.Cmd.Flags().Changed(name) && strings.TrimSpace(runtime.Str(name)) != ""
|
||||
}
|
||||
|
||||
var SheetCreateFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-filter-view",
|
||||
Description: "Create a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true},
|
||||
{Name: "filter-view-name", Desc: "display name (max 100 chars)"},
|
||||
{Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.FlagErrorf("--range must not be empty")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{"range": runtime.Str("range")}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{"range": runtime.Str("range")}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUpdateFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-filter-view",
|
||||
Description: "Update a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "range", Desc: "new filter range"},
|
||||
{Name: "filter-view-name", Desc: "new display name (max 100 chars)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasNonEmptyStringFlag(runtime, "range") &&
|
||||
!hasNonEmptyStringFlag(runtime, "filter-view-name") {
|
||||
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetListFilterViews = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-filter-views",
|
||||
Description: "List all filter views in a sheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetGetFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-filter-view",
|
||||
Description: "Get a filter view by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetDeleteFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-filter-view",
|
||||
Description: "Delete a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetCreateFilterViewCondition = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-filter-view-condition",
|
||||
@@ -83,9 +313,9 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("filter-type") &&
|
||||
!runtime.Cmd.Flags().Changed("compare-type") &&
|
||||
!runtime.Cmd.Flags().Changed("expected") {
|
||||
if !hasNonEmptyStringFlag(runtime, "filter-type") &&
|
||||
!hasNonEmptyStringFlag(runtime, "compare-type") &&
|
||||
!hasNonEmptyStringFlag(runtime, "expected") {
|
||||
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
|
||||
}
|
||||
if s := runtime.Str("expected"); s != "" {
|
||||
@@ -227,7 +457,6 @@ var SheetDeleteFilterViewCondition = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// validateExpectedFlag checks that --expected is a valid JSON array.
|
||||
func validateExpectedFlag(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -239,7 +468,6 @@ func validateExpectedFlag(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildConditionBody constructs the request body for condition create/update.
|
||||
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if includeConditionID {
|
||||
@@ -253,7 +481,6 @@ func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool)
|
||||
}
|
||||
if s := runtime.Str("expected"); s != "" {
|
||||
var arr []interface{}
|
||||
// Validate already ensures this is a valid JSON array.
|
||||
_ = json.Unmarshal([]byte(s), &arr)
|
||||
body["expected"] = arr
|
||||
}
|
||||
@@ -6,11 +6,164 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const sheetImageParentType = "sheet_image"
|
||||
|
||||
var SheetMediaUpload = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+media-upload",
|
||||
Description: "Upload a local image for use as a floating image and return the file_token",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.media:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := validateSheetMediaUploadFile(runtime, runtime.Str("file"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.Desc("chunked media upload (files > 20MB)").
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry.Set("spreadsheet_token", parentNode)
|
||||
}
|
||||
return dry.Desc("multipart/form-data upload").
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("spreadsheet_token", parentNode)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
|
||||
safePath, stat, err := validateSheetMediaUploadFile(runtime, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileName := filepath.Base(safePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
|
||||
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
fileToken, err := uploadSheetMediaFile(runtime, safePath, fileName, stat.Size(), parentNode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"size": stat.Size(),
|
||||
"spreadsheet_token": parentNode,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath string) (string, fileio.FileInfo, error) {
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return "", nil, common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return "", nil, output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
return filePath, stat, nil
|
||||
}
|
||||
|
||||
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if u := runtime.Str("url"); u != "" {
|
||||
if parsed := extractSpreadsheetToken(u); parsed != "" {
|
||||
token = parsed
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
pn := parentNode
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: &pn,
|
||||
})
|
||||
}
|
||||
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
|
||||
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
|
||||
func floatImageBasePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/float_images",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
@@ -46,9 +199,6 @@ func validateFloatImageRange(sheetID, rangeVal string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFloatImageUpdatePayload rejects an update request that carries no
|
||||
// mutable field. Without this, PATCH {} reaches the server as a confusing
|
||||
// no-op or opaque error.
|
||||
func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
|
||||
hasField := runtime.Str("range") != "" ||
|
||||
runtime.Cmd.Flags().Changed("width") ||
|
||||
@@ -61,12 +211,6 @@ func validateFloatImageUpdatePayload(runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFloatImageDims checks the numeric bounds we can verify without
|
||||
// fetching cell dimensions: width/height >= 20 and offset-x/offset-y >= 0.
|
||||
// The upper bounds (offset < anchor cell's width/height) are validated by
|
||||
// the server and surfaced through the 1310246 error hint.
|
||||
// Only flags explicitly supplied by the user are checked, so omitted flags
|
||||
// (which fall back to server defaults) pass through unchanged.
|
||||
func validateFloatImageDims(runtime *common.RuntimeContext) error {
|
||||
if runtime.Cmd.Flags().Changed("width") {
|
||||
if v := runtime.Int("width"); v < 20 {
|
||||
@@ -116,7 +260,6 @@ func buildFloatImageBody(runtime *common.RuntimeContext, includeToken bool) map[
|
||||
return body
|
||||
}
|
||||
|
||||
// SheetCreateFloatImage creates a float image on a sheet.
|
||||
var SheetCreateFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-float-image",
|
||||
@@ -170,7 +313,6 @@ var SheetCreateFloatImage = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// SheetUpdateFloatImage updates a float image's properties.
|
||||
var SheetUpdateFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-float-image",
|
||||
@@ -220,7 +362,6 @@ var SheetUpdateFloatImage = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// SheetGetFloatImage retrieves a single float image.
|
||||
var SheetGetFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-float-image",
|
||||
@@ -255,7 +396,6 @@ var SheetGetFloatImage = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// SheetListFloatImages queries all float images in a sheet.
|
||||
var SheetListFloatImages = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-float-images",
|
||||
@@ -289,7 +429,6 @@ var SheetListFloatImages = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// SheetDeleteFloatImage deletes a float image.
|
||||
var SheetDeleteFloatImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-float-image",
|
||||
369
shortcuts/sheets/lark_sheets_row_column_management.go
Normal file
369
shortcuts/sheets/lark_sheets_row_column_management.go
Normal file
@@ -0,0 +1,369 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetAddDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+add-dimension",
|
||||
Description: "Add rows or columns at the end of a sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
length := runtime.Int("length")
|
||||
if length < 1 || length > 5000 {
|
||||
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"length": runtime.Int("length"),
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"length": runtime.Int("length"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetInsertDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+insert-dimension",
|
||||
Description: "Insert rows or columns at a specified position",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true},
|
||||
{Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") <= runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be greater than --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
body := map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}
|
||||
if s := runtime.Str("inherit-style"); s != "" {
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range").
|
||||
Body(body).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}
|
||||
if s := runtime.Str("inherit-style"); s != "" {
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
|
||||
nil, body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUpdateDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-dimension",
|
||||
Description: "Update row or column properties (visibility, size)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "visible", Type: "bool", Desc: "true to show, false to hide"},
|
||||
{Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
|
||||
return common.FlagErrorf("--fixed-size must be >= 1")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
props := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("visible") {
|
||||
props["visible"] = runtime.Bool("visible")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
props["fixedSize"] = runtime.Int("fixed-size")
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
"dimensionProperties": props,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
props := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("visible") {
|
||||
props["visible"] = runtime.Bool("visible")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
props["fixedSize"] = runtime.Int("fixed-size")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
"dimensionProperties": props,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetMoveDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+move-dimension",
|
||||
Description: "Move rows or columns to a new position",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true},
|
||||
{Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
if runtime.Int("destination-index") < 0 {
|
||||
return common.FlagErrorf("--destination-index must be >= 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension").
|
||||
Body(map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"major_dimension": runtime.Str("dimension"),
|
||||
"start_index": runtime.Int("start-index"),
|
||||
"end_index": runtime.Int("end-index"),
|
||||
},
|
||||
"destination_index": runtime.Int("destination-index"),
|
||||
}).
|
||||
Set("token", token).
|
||||
Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(runtime.Str("sheet-id")),
|
||||
),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"major_dimension": runtime.Str("dimension"),
|
||||
"start_index": runtime.Int("start-index"),
|
||||
"end_index": runtime.Int("end-index"),
|
||||
},
|
||||
"destination_index": runtime.Int("destination-index"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetDeleteDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-dimension",
|
||||
Description: "Delete rows or columns",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -527,6 +527,65 @@ func TestSheetBatchSetStyleValidateSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleValidateRejectsMalformedEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
wantSubst string
|
||||
}{
|
||||
{
|
||||
name: "entry must be object",
|
||||
data: `["bad"]`,
|
||||
wantSubst: "must be an object with ranges and style",
|
||||
},
|
||||
{
|
||||
name: "ranges required",
|
||||
data: `[{"style":{}}]`,
|
||||
wantSubst: ".ranges is required",
|
||||
},
|
||||
{
|
||||
name: "ranges must be array",
|
||||
data: `[{"ranges":"sheet1!A1","style":{}}]`,
|
||||
wantSubst: ".ranges must be a non-empty array of strings",
|
||||
},
|
||||
{
|
||||
name: "ranges must not be empty",
|
||||
data: `[{"ranges":[],"style":{}}]`,
|
||||
wantSubst: ".ranges must be a non-empty array of strings",
|
||||
},
|
||||
{
|
||||
name: "range must include sheet prefix",
|
||||
data: `[{"ranges":["A1"],"style":{}}]`,
|
||||
wantSubst: "must include a sheetId! prefix",
|
||||
},
|
||||
{
|
||||
name: "style required",
|
||||
data: `[{"ranges":["sheet1!A1:B2"]}]`,
|
||||
wantSubst: ".style is required",
|
||||
},
|
||||
{
|
||||
name: "style must be object",
|
||||
data: `[{"ranges":["sheet1!A1:B2"],"style":"bad"}]`,
|
||||
wantSubst: ".style must be a JSON object",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "data": tt.data,
|
||||
}, nil)
|
||||
err := SheetBatchSetStyle.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchSetStyleDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -144,6 +145,23 @@ func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"title": "项目排期",
|
||||
"folder-token": "fldcn123",
|
||||
"headers": "",
|
||||
"data": "",
|
||||
},
|
||||
nil, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"folder_token":"fldcn123"`) {
|
||||
t.Fatalf("DryRun should include folder_token, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreatePreservesBackendURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -106,6 +106,62 @@ func TestSheetAddDimensionValidateWithURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDimensionShortcutsValidateRejectURLAndTokenTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
strFlags map[string]string
|
||||
intFlags map[string]int
|
||||
boolFlags map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "add",
|
||||
shortcut: SheetAddDimension,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
intFlags: map[string]int{"length": 1},
|
||||
},
|
||||
{
|
||||
name: "insert",
|
||||
shortcut: SheetInsertDimension,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": ""},
|
||||
intFlags: map[string]int{"start-index": 0, "end-index": 1},
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: SheetUpdateDimension,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
intFlags: map[string]int{"start-index": 1, "end-index": 1},
|
||||
boolFlags: map[string]bool{"visible": true},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
shortcut: SheetMoveDimension,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
intFlags: map[string]int{"start-index": 0, "end-index": 0, "destination-index": 1},
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: SheetDeleteDimension,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "dimension": "ROWS"},
|
||||
intFlags: map[string]int{"start-index": 1, "end-index": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, tt.boolFlags)
|
||||
err := tt.shortcut.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual exclusivity error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAddDimensionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
140
shortcuts/sheets/lark_sheets_sheet_export_test.go
Normal file
140
shortcuts/sheets/lark_sheets_sheet_export_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSheetExportValidateRejectsURLAndTokenTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t, map[string]string{
|
||||
"url": "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"file-extension": "xlsx",
|
||||
"output-path": "",
|
||||
"sheet-id": "",
|
||||
}, nil, nil)
|
||||
err := SheetExport.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual exclusivity error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportValidateRequiresSheetIDForCSV(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t, map[string]string{
|
||||
"url": "",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"file-extension": "csv",
|
||||
"output-path": "",
|
||||
"sheet-id": "",
|
||||
}, nil, nil)
|
||||
err := SheetExport.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--sheet-id is required when --file-extension is csv") {
|
||||
t.Fatalf("expected csv sheet-id validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportValidateAllowsCSVWithSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t, map[string]string{
|
||||
"url": "",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"file-extension": "csv",
|
||||
"output-path": "",
|
||||
"sheet-id": "sheet1",
|
||||
}, nil, nil)
|
||||
if err := SheetExport.Validate(context.Background(), rt); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportDryRunIncludesSubIDForCSV(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t, map[string]string{
|
||||
"url": "",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"file-extension": "csv",
|
||||
"output-path": "",
|
||||
"sheet-id": "sheet1",
|
||||
}, nil, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetExport.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"sub_id":"sheet1"`) {
|
||||
t.Fatalf("DryRun should include sub_id for csv export, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCommandRejectsInvalidFileExtension(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetExport, []string{
|
||||
"+export",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--file-extension", "pdf",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), `allowed: xlsx, csv`) {
|
||||
t.Fatalf("expected invalid file-extension error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportExecuteWithoutOutputPathReturnsMetadataOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"ticket": "tk_123",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"file_token": "box_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetExport, []string{
|
||||
"+export",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--file-extension", "xlsx",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
if gjson.Get(got, "data.file_token").String() != "box_123" || gjson.Get(got, "data.ticket").String() != "tk_123" {
|
||||
t.Fatalf("stdout should return export metadata, got: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"saved_path"`) {
|
||||
t.Fatalf("stdout should not include saved_path when --output-path is omitted: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,35 @@ func TestCreateFilterViewValidateMissingToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFilterViewTokenRejectsURLAndTokenTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"sheet-id": "s1",
|
||||
"range": "s1!A1:H14",
|
||||
"filter-view-name": "",
|
||||
"filter-view-id": "",
|
||||
}, nil)
|
||||
_, err := validateFilterViewToken(rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual exclusivity error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewValidateRejectsEmptyRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "",
|
||||
"filter-view-name": "", "filter-view-id": "",
|
||||
}, nil)
|
||||
err := SheetCreateFilterView.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--range must not be empty") {
|
||||
t.Fatalf("expected empty range error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewValidateSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
@@ -137,6 +166,22 @@ func TestUpdateFilterViewRejectsNoFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewRejectsBlankFieldsOnly(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
|
||||
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--range", "", "--filter-view-name", "",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error when only blank update fields are provided, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "at least one") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFilterViews ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestListFilterViewsDryRun(t *testing.T) {
|
||||
692
shortcuts/sheets/lark_sheets_sheet_manage_test.go
Normal file
692
shortcuts/sheets/lark_sheets_sheet_manage_test.go
Normal file
@@ -0,0 +1,692 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSheetCreateSheetValidateMissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"url": "", "spreadsheet-token": "", "title": "Sheet 2"},
|
||||
nil, nil)
|
||||
err := SheetCreateSheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
|
||||
t.Fatalf("expected token error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetManageValidateRejectsURLAndTokenTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args map[string]string
|
||||
}{
|
||||
{
|
||||
name: "create-sheet",
|
||||
shortcut: SheetCreateSheet,
|
||||
args: map[string]string{
|
||||
"url": "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"title": "Data",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "copy-sheet",
|
||||
shortcut: SheetCopySheet,
|
||||
args: map[string]string{
|
||||
"url": "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"sheet-id": "sheet1",
|
||||
"title": "Copy",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete-sheet",
|
||||
shortcut: SheetDeleteSheet,
|
||||
args: map[string]string{
|
||||
"url": "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"sheet-id": "sheet1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update-sheet",
|
||||
shortcut: SheetUpdateSheet,
|
||||
args: map[string]string{
|
||||
"url": "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"sheet-id": "sheet1",
|
||||
"title": "Renamed",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, tt.args, nil, nil)
|
||||
err := tt.shortcut.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual exclusivity error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateSheetValidateRejectsInvalidTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
wantSubst string
|
||||
}{
|
||||
{name: "special chars", title: "bad/title", wantSubst: "must not contain"},
|
||||
{name: "empty", title: "", wantSubst: "must not be empty"},
|
||||
{name: "tab", title: "bad\ttitle", wantSubst: "tabs or line breaks"},
|
||||
{name: "newline", title: "bad\ntitle", wantSubst: "tabs or line breaks"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "sht1", "title": tt.title},
|
||||
nil, nil)
|
||||
err := SheetCreateSheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("expected title error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateSheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "sht1", "title": "Data"},
|
||||
map[string]int{"index": -1}, nil)
|
||||
err := SheetCreateSheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") {
|
||||
t.Fatalf("expected index validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCopySheetValidateRejectsInvalidTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "bad\ttitle"},
|
||||
nil, nil)
|
||||
err := SheetCopySheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "tabs or line breaks") {
|
||||
t.Fatalf("expected title error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCopySheetValidateRejectsNegativeIndexWhenTitleProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": "Copy"},
|
||||
map[string]int{"index": -1}, nil)
|
||||
err := SheetCopySheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--index must be >= 0") {
|
||||
t.Fatalf("expected index validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateSheetValidateRejectsEmptyTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "sht1", "sheet-id": "sheet1", "title": ""},
|
||||
nil, nil)
|
||||
err := SheetUpdateSheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("expected empty-title error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateSheetDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "shtTOKEN", "title": "Data"},
|
||||
map[string]int{"index": 0}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCreateSheet.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) {
|
||||
t.Fatalf("DryRun URL mismatch: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"addSheet"`) || !strings.Contains(got, `"title":"Data"`) || !strings.Contains(got, `"index":0`) {
|
||||
t.Fatalf("DryRun body mismatch: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateSheetExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"replies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"addSheet": map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"sheetId": "sheet_new",
|
||||
"title": "Data",
|
||||
"index": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetCreateSheet, []string{
|
||||
"+create-sheet",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--title", "Data",
|
||||
"--index", "0",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_new" {
|
||||
t.Fatalf("stdout missing sheet_id: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
requests, _ := body["requests"].([]interface{})
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
req0, _ := requests[0].(map[string]interface{})
|
||||
addSheet, _ := req0["addSheet"].(map[string]interface{})
|
||||
props, _ := addSheet["properties"].(map[string]interface{})
|
||||
if props["title"] != "Data" {
|
||||
t.Fatalf("request title = %#v", props["title"])
|
||||
}
|
||||
if idx, ok := props["index"].(float64); !ok || idx != 0 {
|
||||
t.Fatalf("request index = %#v", props["index"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCopySheetValidateMissingSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "sht1", "sheet-id": ""},
|
||||
nil, nil)
|
||||
err := SheetCopySheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
|
||||
t.Fatalf("expected sheet-id error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCopySheetDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "title": "Copy"},
|
||||
map[string]int{"index": 2}, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetCopySheet.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) {
|
||||
t.Fatalf("DryRun URL mismatch: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"copySheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) || !strings.Contains(got, `"title":"Copy"`) {
|
||||
t.Fatalf("DryRun body mismatch: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"[2] Move copied sheet to requested index"`) || !strings.Contains(got, `\u003ccopied_sheet_id\u003e`) || !strings.Contains(got, `"index":2`) {
|
||||
t.Fatalf("DryRun should describe follow-up move: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCopySheetExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
copyStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"replies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"copySheet": map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"sheetId": "sheet_copy",
|
||||
"title": "Copy",
|
||||
"index": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
moveStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"replies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"updateSheet": map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"sheetId": "sheet_copy",
|
||||
"index": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(copyStub)
|
||||
reg.Register(moveStub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetCopySheet, []string{
|
||||
"+copy-sheet",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "Copy",
|
||||
"--index", "2",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet_copy" {
|
||||
t.Fatalf("stdout missing copied sheet id: %s", stdout.String())
|
||||
}
|
||||
if gjson.Get(stdout.String(), "data.sheet.index").Int() != 2 {
|
||||
t.Fatalf("stdout missing moved index: %s", stdout.String())
|
||||
}
|
||||
|
||||
var copyBody map[string]interface{}
|
||||
if err := json.Unmarshal(copyStub.CapturedBody, ©Body); err != nil {
|
||||
t.Fatalf("parse copy body: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(copyStub.CapturedBody), `"copySheet"`) {
|
||||
t.Fatalf("copy request missing copySheet: %s", string(copyStub.CapturedBody))
|
||||
}
|
||||
if !strings.Contains(string(moveStub.CapturedBody), `"updateSheet"`) || !strings.Contains(string(moveStub.CapturedBody), `"index":2`) {
|
||||
t.Fatalf("move request mismatch: %s", string(moveStub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCopySheetExecuteMoveFailureIncludesCopiedSheetRecovery(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"replies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"copySheet": map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"sheetId": "sheet_copy",
|
||||
"title": "Copy",
|
||||
"index": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1310211,
|
||||
"msg": "wrong sheet id",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-move-failed",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetCopySheet, []string{
|
||||
"+copy-sheet",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "Copy",
|
||||
"--index", "2",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected move failure, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with detail, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Code != 1310211 {
|
||||
t.Fatalf("error code = %d, want 1310211", exitErr.Detail.Code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, `sheet copied successfully as "sheet_copy"`) {
|
||||
t.Fatalf("message missing copied sheet id: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "do not retry +copy-sheet") {
|
||||
t.Fatalf("hint missing retry guard: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2") {
|
||||
t.Fatalf("hint missing recovery command: %q", exitErr.Detail.Hint)
|
||||
}
|
||||
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["partial_success"] != true {
|
||||
t.Fatalf("partial_success = %#v, want true", detail["partial_success"])
|
||||
}
|
||||
if detail["sheet_id"] != "sheet_copy" {
|
||||
t.Fatalf("sheet_id = %#v, want %q", detail["sheet_id"], "sheet_copy")
|
||||
}
|
||||
if detail["requested_index"] != 2 {
|
||||
t.Fatalf("requested_index = %#v, want 2", detail["requested_index"])
|
||||
}
|
||||
if detail["retry_command"] != "lark-cli sheets +update-sheet --spreadsheet-token shtTOKEN --sheet-id sheet_copy --index 2" {
|
||||
t.Fatalf("retry_command = %#v", detail["retry_command"])
|
||||
}
|
||||
if detail["log_id"] != "log-move-failed" {
|
||||
t.Fatalf("log_id = %#v, want %q", detail["log_id"], "log-move-failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteSheetDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"},
|
||||
nil, nil)
|
||||
got := mustMarshalSheetsDryRun(t, SheetDeleteSheet.DryRun(context.Background(), rt))
|
||||
if !strings.Contains(got, `"method":"POST"`) {
|
||||
t.Fatalf("DryRun should use POST: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`) {
|
||||
t.Fatalf("DryRun URL mismatch: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"deleteSheet"`) || !strings.Contains(got, `"sheetId":"sheet1"`) {
|
||||
t.Fatalf("DryRun body mismatch: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetDeleteSheetExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"replies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"deleteSheet": map[string]interface{}{
|
||||
"result": true,
|
||||
"sheetId": "sheet1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunSheets(t, SheetDeleteSheet, []string{
|
||||
"+delete-sheet",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !gjson.Get(stdout.String(), "data.deleted").Bool() {
|
||||
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
|
||||
}
|
||||
if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" {
|
||||
t.Fatalf("stdout missing sheet_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateSheetValidateRequiresMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1"},
|
||||
nil, nil)
|
||||
err := SheetUpdateSheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "specify at least one") {
|
||||
t.Fatalf("expected mutation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateSheetValidateRejectsBadProtectionConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
strFlags map[string]string
|
||||
intFlags map[string]int
|
||||
wantSubst string
|
||||
}{
|
||||
{
|
||||
name: "lock-info requires lock",
|
||||
strFlags: map[string]string{
|
||||
"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock-info": "private",
|
||||
},
|
||||
wantSubst: "--lock when updating protection settings",
|
||||
},
|
||||
{
|
||||
name: "user-ids requires user-id-type",
|
||||
strFlags: map[string]string{
|
||||
"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "lock": "LOCK",
|
||||
"user-ids": `["ou_1"]`,
|
||||
},
|
||||
wantSubst: "--user-ids requires --user-id-type",
|
||||
},
|
||||
{
|
||||
name: "negative frozen rows rejected",
|
||||
strFlags: map[string]string{
|
||||
"spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1",
|
||||
},
|
||||
intFlags: map[string]int{"frozen-row-count": -1},
|
||||
wantSubst: "--frozen-row-count must be >= 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newDimTestRuntime(t, tt.strFlags, tt.intFlags, nil)
|
||||
err := SheetUpdateSheet.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantSubst) {
|
||||
t.Fatalf("want error containing %q, got: %v", tt.wantSubst, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateSheetDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newDimTestRuntime(t,
|
||||
map[string]string{
|
||||
"spreadsheet-token": "shtTOKEN",
|
||||
"sheet-id": "sheet1",
|
||||
"title": "Hidden Sheet",
|
||||
"lock": "LOCK",
|
||||
"lock-info": "private",
|
||||
"user-ids": `["ou_1"]`,
|
||||
"user-id-type": "open_id",
|
||||
},
|
||||
map[string]int{
|
||||
"index": 3,
|
||||
"frozen-row-count": 2,
|
||||
"frozen-col-count": 1,
|
||||
},
|
||||
map[string]bool{"hidden": false},
|
||||
)
|
||||
got := mustMarshalSheetsDryRun(t, SheetUpdateSheet.DryRun(context.Background(), rt))
|
||||
for _, want := range []string{
|
||||
`"/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update"`,
|
||||
`"user_id_type":"open_id"`,
|
||||
`"sheetId":"sheet1"`,
|
||||
`"title":"Hidden Sheet"`,
|
||||
`"index":3`,
|
||||
`"hidden":false`,
|
||||
`"frozenRowCount":2`,
|
||||
`"frozenColCount":1`,
|
||||
`"lock":"LOCK"`,
|
||||
`"lockInfo":"private"`,
|
||||
`"userIDs":["ou_1"]`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("DryRun missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetUpdateSheetExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/sheets_batch_update?user_id_type=open_id",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"replies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"updateSheet": map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"sheetId": "sheet1",
|
||||
"title": "Renamed",
|
||||
"index": 1,
|
||||
"hidden": true,
|
||||
"frozenRowCount": 2,
|
||||
"frozenColCount": 1,
|
||||
"protect": map[string]interface{}{
|
||||
"lock": "LOCK",
|
||||
"lockInfo": "private",
|
||||
"userIDs": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunSheets(t, SheetUpdateSheet, []string{
|
||||
"+update-sheet",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "Renamed",
|
||||
"--index", "1",
|
||||
"--hidden=true",
|
||||
"--frozen-row-count", "2",
|
||||
"--frozen-col-count", "1",
|
||||
"--lock", "LOCK",
|
||||
"--lock-info", "private",
|
||||
"--user-ids", `["ou_1"]`,
|
||||
"--user-id-type", "open_id",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gjson.Get(stdout.String(), "data.sheet_id").String() != "sheet1" {
|
||||
t.Fatalf("stdout missing sheet_id: %s", stdout.String())
|
||||
}
|
||||
if gjson.Get(stdout.String(), "data.sheet.title").String() != "Renamed" {
|
||||
t.Fatalf("stdout missing title: %s", stdout.String())
|
||||
}
|
||||
if gjson.Get(stdout.String(), "data.sheet.grid_properties.frozen_row_count").Int() != 2 {
|
||||
t.Fatalf("stdout missing frozen_row_count: %s", stdout.String())
|
||||
}
|
||||
if gjson.Get(stdout.String(), "data.sheet.protect.lock_info").String() != "private" {
|
||||
t.Fatalf("stdout missing lock_info: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
requests, ok := body["requests"].([]interface{})
|
||||
if !ok || len(requests) != 1 {
|
||||
t.Fatalf("unexpected requests body: %#v", body)
|
||||
}
|
||||
req0, _ := requests[0].(map[string]interface{})
|
||||
updateSheet, _ := req0["updateSheet"].(map[string]interface{})
|
||||
props, _ := updateSheet["properties"].(map[string]interface{})
|
||||
if props["sheetId"] != "sheet1" || props["title"] != "Renamed" {
|
||||
t.Fatalf("unexpected properties: %#v", props)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateSheetOutputOmitsBlankTitleWhenTitleNotChanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
out, ok := buildUpdateSheetOutput("shtTOKEN", map[string]interface{}{
|
||||
"replies": []interface{}{
|
||||
map[string]interface{}{
|
||||
"updateSheet": map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"sheetId": "sheet1",
|
||||
"title": "",
|
||||
"hidden": false,
|
||||
"frozenRowCount": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, false)
|
||||
if !ok {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
sheet, _ := out["sheet"].(map[string]interface{})
|
||||
if _, exists := sheet["title"]; exists {
|
||||
t.Fatalf("blank title should be omitted when title is unchanged: %#v", sheet)
|
||||
}
|
||||
if sheet["sheet_id"] != "sheet1" {
|
||||
t.Fatalf("unexpected sheet output: %#v", sheet)
|
||||
}
|
||||
}
|
||||
721
shortcuts/sheets/lark_sheets_sheet_management.go
Normal file
721
shortcuts/sheets/lark_sheets_sheet_management.go
Normal file
@@ -0,0 +1,721 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var sheetProtectLockValues = []string{"LOCK", "UNLOCK"}
|
||||
|
||||
func sheetBatchUpdatePath(token string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/sheets_batch_update", validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func validateSheetManageToken(runtime *common.RuntimeContext) (string, error) {
|
||||
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
|
||||
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(runtime.Str("url"))
|
||||
if url == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func validateSheetID(flagName, sheetID string) error {
|
||||
if strings.TrimSpace(sheetID) == "" {
|
||||
return common.FlagErrorf("specify --%s", flagName)
|
||||
}
|
||||
if err := validate.RejectControlChars(sheetID, flagName); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSheetTitle(flagName, title string) error {
|
||||
if title == "" {
|
||||
return common.FlagErrorf("--%s must not be empty", flagName)
|
||||
}
|
||||
if strings.ContainsAny(title, "\t\r\n") {
|
||||
return common.FlagErrorf("--%s must not contain tabs or line breaks", flagName)
|
||||
}
|
||||
if err := validate.RejectControlChars(title, flagName); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
if len([]rune(title)) > 100 {
|
||||
return common.FlagErrorf("--%s must be <= 100 characters", flagName)
|
||||
}
|
||||
if strings.ContainsAny(title, `/\?*[]:`) || strings.Contains(title, `\`) {
|
||||
return common.FlagErrorf("--%s must not contain any of / \\ ? * [ ] :", flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNonNegativeInt(flagName string, value int) error {
|
||||
if value < 0 {
|
||||
return common.FlagErrorf("--%s must be >= 0, got %d", flagName, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSheetCreateProperties(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
properties := map[string]interface{}{}
|
||||
if runtime.Changed("title") {
|
||||
properties["title"] = runtime.Str("title")
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
properties["index"] = runtime.Int("index")
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
func buildCreateSheetBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
"addSheet": map[string]interface{}{
|
||||
"properties": buildSheetCreateProperties(runtime),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildCopySheetBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
copySheet := map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
},
|
||||
}
|
||||
if runtime.Changed("title") {
|
||||
copySheet["destination"] = map[string]interface{}{
|
||||
"title": runtime.Str("title"),
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
"copySheet": copySheet,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeleteSheetBody(sheetID string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
"deleteSheet": map[string]interface{}{
|
||||
"sheetId": sheetID,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildMoveCopiedSheetBody(sheetID string, index int) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
"updateSheet": map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"sheetId": sheetID,
|
||||
"index": index,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSheetProperties(properties map[string]interface{}, titleChanged bool) map[string]interface{} {
|
||||
sheet := map[string]interface{}{}
|
||||
if v, ok := properties["sheetId"]; ok {
|
||||
sheet["sheet_id"] = v
|
||||
}
|
||||
if v, ok := properties["title"]; ok {
|
||||
if title, ok := v.(string); !ok || title != "" || titleChanged {
|
||||
sheet["title"] = v
|
||||
}
|
||||
}
|
||||
if v, ok := properties["index"]; ok {
|
||||
sheet["index"] = v
|
||||
}
|
||||
if v, ok := properties["hidden"]; ok {
|
||||
sheet["hidden"] = v
|
||||
}
|
||||
|
||||
grid := map[string]interface{}{}
|
||||
if v, ok := properties["frozenRowCount"]; ok {
|
||||
grid["frozen_row_count"] = v
|
||||
}
|
||||
if v, ok := properties["frozenColCount"]; ok {
|
||||
grid["frozen_column_count"] = v
|
||||
}
|
||||
if len(grid) > 0 {
|
||||
sheet["grid_properties"] = grid
|
||||
}
|
||||
|
||||
if protect, ok := properties["protect"].(map[string]interface{}); ok {
|
||||
outProtect := map[string]interface{}{}
|
||||
if v, ok := protect["lock"]; ok {
|
||||
outProtect["lock"] = v
|
||||
}
|
||||
if v, ok := protect["lockInfo"]; ok {
|
||||
outProtect["lock_info"] = v
|
||||
}
|
||||
if v, ok := protect["userIDs"]; ok {
|
||||
outProtect["user_ids"] = v
|
||||
}
|
||||
if len(outProtect) > 0 {
|
||||
sheet["protect"] = outProtect
|
||||
}
|
||||
}
|
||||
return sheet
|
||||
}
|
||||
|
||||
func firstReply(data map[string]interface{}) (map[string]interface{}, bool) {
|
||||
replies, ok := data["replies"].([]interface{})
|
||||
if !ok || len(replies) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
reply, ok := replies[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
func buildOperateSheetOutput(token string, data map[string]interface{}, opKey string, titleChanged bool) (map[string]interface{}, bool) {
|
||||
reply, ok := firstReply(data)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
op, ok := reply[opKey].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
properties, ok := op["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
sheet := normalizeSheetProperties(properties, titleChanged)
|
||||
out := map[string]interface{}{
|
||||
"spreadsheet_token": token,
|
||||
"sheet": sheet,
|
||||
}
|
||||
if sheetID, ok := sheet["sheet_id"].(string); ok && sheetID != "" {
|
||||
out["sheet_id"] = sheetID
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func buildDeleteSheetOutput(token string, sheetID string, data map[string]interface{}) (map[string]interface{}, bool) {
|
||||
reply, ok := firstReply(data)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
del, ok := reply["deleteSheet"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"spreadsheet_token": token,
|
||||
"sheet_id": sheetID,
|
||||
"deleted": true,
|
||||
}
|
||||
if v, ok := del["sheetId"].(string); ok && v != "" {
|
||||
out["sheet_id"] = v
|
||||
}
|
||||
if v, ok := del["result"].(bool); ok {
|
||||
out["deleted"] = v
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func mergeSheetOutputs(base, overlay map[string]interface{}) map[string]interface{} {
|
||||
if base == nil {
|
||||
return overlay
|
||||
}
|
||||
if overlay == nil {
|
||||
return base
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range overlay {
|
||||
if k == "sheet" {
|
||||
baseSheet, _ := out["sheet"].(map[string]interface{})
|
||||
overlaySheet, _ := v.(map[string]interface{})
|
||||
mergedSheet := map[string]interface{}{}
|
||||
for sk, sv := range baseSheet {
|
||||
mergedSheet[sk] = sv
|
||||
}
|
||||
for sk, sv := range overlaySheet {
|
||||
mergedSheet[sk] = sv
|
||||
}
|
||||
out["sheet"] = mergedSheet
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeSheetErrorDetail(detail interface{}, overlay map[string]interface{}) interface{} {
|
||||
if len(overlay) == 0 {
|
||||
return detail
|
||||
}
|
||||
if detail == nil {
|
||||
return overlay
|
||||
}
|
||||
if existing, ok := detail.(map[string]interface{}); ok {
|
||||
merged := map[string]interface{}{}
|
||||
for k, v := range existing {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range overlay {
|
||||
merged[k] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
merged := map[string]interface{}{}
|
||||
for k, v := range overlay {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["cause_detail"] = detail
|
||||
return merged
|
||||
}
|
||||
|
||||
func copySheetMoveRetryCommand(token, sheetID string, index int) string {
|
||||
return fmt.Sprintf("lark-cli sheets +update-sheet --spreadsheet-token %s --sheet-id %s --index %d", token, sheetID, index)
|
||||
}
|
||||
|
||||
func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
|
||||
if strings.TrimSpace(sheetID) == "" {
|
||||
return err
|
||||
}
|
||||
|
||||
retryCommand := copySheetMoveRetryCommand(token, sheetID, index)
|
||||
msg := fmt.Sprintf("sheet copied successfully as %q, but moving it to index %d failed", sheetID, index)
|
||||
hint := fmt.Sprintf(
|
||||
"do not retry +copy-sheet: the new sheet already exists as %s\nretry only the move with: %s",
|
||||
sheetID,
|
||||
retryCommand,
|
||||
)
|
||||
detail := map[string]interface{}{
|
||||
"partial_success": true,
|
||||
"failed_step": "move_copied_sheet",
|
||||
"spreadsheet_token": token,
|
||||
"sheet_id": sheetID,
|
||||
"requested_index": index,
|
||||
"retry_command": retryCommand,
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
if upstreamHint := strings.TrimSpace(exitErr.Detail.Hint); upstreamHint != "" {
|
||||
hint = upstreamHint + "\n" + hint
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: fmt.Sprintf("%s: %s", msg, exitErr.Detail.Message),
|
||||
Hint: hint,
|
||||
ConsoleURL: exitErr.Detail.ConsoleURL,
|
||||
Risk: exitErr.Detail.Risk,
|
||||
Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail),
|
||||
},
|
||||
Err: err,
|
||||
Raw: exitErr.Raw,
|
||||
}
|
||||
}
|
||||
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Message: fmt.Sprintf("%s: %v", msg, err),
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func validateUpdateSheetFlags(runtime *common.RuntimeContext) error {
|
||||
if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("title") {
|
||||
if err := validateSheetTitle("title", runtime.Str("title")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if runtime.Changed("frozen-row-count") {
|
||||
if err := validateNonNegativeInt("frozen-row-count", runtime.Int("frozen-row-count")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if runtime.Changed("frozen-col-count") {
|
||||
if err := validateNonNegativeInt("frozen-col-count", runtime.Int("frozen-col-count")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if runtime.Changed("lock-info") {
|
||||
if err := validate.RejectControlChars(runtime.Str("lock-info"), "lock-info"); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
hasProtectConfig := runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids")
|
||||
if hasProtectConfig {
|
||||
lock := runtime.Str("lock")
|
||||
if !runtime.Changed("lock") {
|
||||
return common.FlagErrorf("specify --lock when updating protection settings")
|
||||
}
|
||||
if runtime.Changed("lock-info") && lock != "LOCK" {
|
||||
return common.FlagErrorf("--lock-info requires --lock LOCK")
|
||||
}
|
||||
if runtime.Changed("user-ids") {
|
||||
if lock != "LOCK" {
|
||||
return common.FlagErrorf("--user-ids requires --lock LOCK")
|
||||
}
|
||||
if runtime.Str("user-id-type") == "" {
|
||||
return common.FlagErrorf("--user-ids requires --user-id-type")
|
||||
}
|
||||
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return common.FlagErrorf("--user-ids must not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasUpdate := runtime.Changed("title") ||
|
||||
runtime.Changed("index") ||
|
||||
runtime.Changed("hidden") ||
|
||||
runtime.Changed("frozen-row-count") ||
|
||||
runtime.Changed("frozen-col-count") ||
|
||||
hasProtectConfig
|
||||
if !hasUpdate {
|
||||
return common.FlagErrorf("specify at least one of --title, --index, --hidden, --frozen-row-count, --frozen-col-count, --lock, --lock-info, or --user-ids")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUpdateSheetBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
properties := map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
}
|
||||
|
||||
if runtime.Changed("title") {
|
||||
properties["title"] = runtime.Str("title")
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
properties["index"] = runtime.Int("index")
|
||||
}
|
||||
if runtime.Changed("hidden") {
|
||||
properties["hidden"] = runtime.Bool("hidden")
|
||||
}
|
||||
if runtime.Changed("frozen-row-count") {
|
||||
properties["frozenRowCount"] = runtime.Int("frozen-row-count")
|
||||
}
|
||||
if runtime.Changed("frozen-col-count") {
|
||||
properties["frozenColCount"] = runtime.Int("frozen-col-count")
|
||||
}
|
||||
if runtime.Changed("lock") || runtime.Changed("lock-info") || runtime.Changed("user-ids") {
|
||||
protect := map[string]interface{}{
|
||||
"lock": runtime.Str("lock"),
|
||||
}
|
||||
if runtime.Changed("lock-info") {
|
||||
protect["lockInfo"] = runtime.Str("lock-info")
|
||||
}
|
||||
if runtime.Changed("user-ids") {
|
||||
userIDs, err := parseJSONStringArray("user-ids", runtime.Str("user-ids"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protect["userIDs"] = userIDs
|
||||
}
|
||||
properties["protect"] = protect
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"requests": []interface{}{
|
||||
map[string]interface{}{
|
||||
"updateSheet": map[string]interface{}{
|
||||
"properties": properties,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildUpdateSheetOutput(token string, data map[string]interface{}, titleChanged bool) (map[string]interface{}, bool) {
|
||||
return buildOperateSheetOutput(token, data, "updateSheet", titleChanged)
|
||||
}
|
||||
|
||||
var SheetCreateSheet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-sheet",
|
||||
Description: "Create a sheet in an existing spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "title", Desc: "sheet title"},
|
||||
{Name: "index", Type: "int", Desc: "sheet index (0-based)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("title") {
|
||||
if err := validateSheetTitle("title", runtime.Str("title")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
|
||||
Body(buildCreateSheetBody(runtime)).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCreateSheetBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out, ok := buildOperateSheetOutput(token, data, "addSheet", runtime.Changed("title")); ok {
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetCopySheet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+copy-sheet",
|
||||
Description: "Copy a sheet within a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "source sheet ID", Required: true},
|
||||
{Name: "title", Desc: "new sheet title"},
|
||||
{Name: "index", Type: "int", Desc: "new sheet index (0-based)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSheetID("sheet-id", runtime.Str("sheet-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("title") {
|
||||
if err := validateSheetTitle("title", runtime.Str("title")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
if err := validateNonNegativeInt("index", runtime.Int("index")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
dry := common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
|
||||
Desc("[1] Copy sheet").
|
||||
Body(buildCopySheetBody(runtime)).
|
||||
Set("token", token)
|
||||
if runtime.Changed("index") {
|
||||
dry.POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
|
||||
Desc("[2] Move copied sheet to requested index").
|
||||
Body(buildMoveCopiedSheetBody("<copied_sheet_id>", runtime.Int("index"))).
|
||||
Set("token", token)
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildCopySheetBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, ok := buildOperateSheetOutput(token, data, "copySheet", runtime.Changed("title"))
|
||||
if !ok {
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
if runtime.Changed("index") {
|
||||
copiedSheetID, _ := out["sheet_id"].(string)
|
||||
moveResp, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildMoveCopiedSheetBody(copiedSheetID, runtime.Int("index")))
|
||||
if err != nil {
|
||||
return wrapCopySheetMoveError(err, token, copiedSheetID, runtime.Int("index"))
|
||||
}
|
||||
if moveOut, ok := buildUpdateSheetOutput(token, moveResp, false); ok {
|
||||
out = mergeSheetOutputs(out, moveOut)
|
||||
}
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetDeleteSheet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-sheet",
|
||||
Description: "Delete a sheet from a spreadsheet",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID to delete", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSheetID("sheet-id", runtime.Str("sheet-id"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
|
||||
Body(buildDeleteSheetBody(runtime.Str("sheet-id"))).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), nil, buildDeleteSheetBody(runtime.Str("sheet-id")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out, ok := buildDeleteSheetOutput(token, runtime.Str("sheet-id"), data); ok {
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUpdateSheet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-sheet",
|
||||
Description: "Update sheet properties",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "title", Desc: "sheet title"},
|
||||
{Name: "index", Type: "int", Desc: "sheet index (0-based)"},
|
||||
{Name: "hidden", Type: "bool", Desc: "set true to hide or false to unhide"},
|
||||
{Name: "frozen-row-count", Type: "int", Desc: "freeze rows through this count (0 unfreezes)"},
|
||||
{Name: "frozen-col-count", Type: "int", Desc: "freeze columns through this count (0 unfreezes)"},
|
||||
{Name: "lock", Desc: "sheet protection mode", Enum: sheetProtectLockValues},
|
||||
{Name: "lock-info", Desc: "protection remark"},
|
||||
{Name: "user-ids", Desc: `extra editor IDs for protected sheet as JSON array (e.g. '["ou_xxx"]')`},
|
||||
{Name: "user-id-type", Desc: "user ID type for --user-ids", Enum: []string{"open_id", "union_id", "lark_id", "user_id"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateUpdateSheetFlags(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
body, _ := buildUpdateSheetBody(runtime)
|
||||
dry := common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/sheets_batch_update").
|
||||
Body(body).
|
||||
Set("token", token)
|
||||
if userIDType := runtime.Str("user-id-type"); userIDType != "" {
|
||||
dry.Params(map[string]interface{}{"user_id_type": userIDType})
|
||||
}
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
body, err := buildUpdateSheetBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var params map[string]interface{}
|
||||
if userIDType := runtime.Str("user-id-type"); userIDType != "" {
|
||||
params = map[string]interface{}{"user_id_type": userIDType}
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", sheetBatchUpdatePath(token), params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out, ok := buildUpdateSheetOutput(token, data, runtime.Changed("title")); ok {
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -27,6 +27,41 @@ func TestSheetMediaUploadValidateMissingToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadValidateMissingFileBeforeDryRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "missing.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "file not found") {
|
||||
t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadValidateRejectsDirectoryBeforeDryRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.Mkdir("imgdir", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "shtSTUB",
|
||||
"--file", "imgdir",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("expected regular-file error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
@@ -146,3 +146,123 @@ func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Fatalf("SheetFind.DryRun() = %s, want normalized escaped separator", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetFindValidateMismatchedRangeSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "target",
|
||||
"range": "sheet2!A1:B2",
|
||||
}, map[string]bool{
|
||||
"ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false,
|
||||
})
|
||||
err := SheetFind.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match --sheet-id") {
|
||||
t.Fatalf("expected mismatch error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellDataValidateRejectsURLAndTokenTogether(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
strFlags map[string]string
|
||||
boolFlags map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "read",
|
||||
shortcut: SheetRead,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN"},
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
shortcut: SheetWrite,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`},
|
||||
},
|
||||
{
|
||||
name: "append",
|
||||
shortcut: SheetAppend,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "values": `[[1]]`},
|
||||
},
|
||||
{
|
||||
name: "find",
|
||||
shortcut: SheetFind,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "x"},
|
||||
boolFlags: map[string]bool{"ignore-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false},
|
||||
},
|
||||
{
|
||||
name: "replace",
|
||||
shortcut: SheetReplace,
|
||||
strFlags: map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "shtTOKEN", "sheet-id": "sheet1", "find": "a", "replacement": "b"},
|
||||
boolFlags: map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, tt.strFlags, tt.boolFlags)
|
||||
err := tt.shortcut.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected mutual exclusivity error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellDataValidateRejectsInvalidSpreadsheetURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rt := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "https://example.feishu.cn/docx/doxcnNotSheet",
|
||||
"spreadsheet-token": "",
|
||||
}, nil)
|
||||
err := SheetRead.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "spreadsheet URL") {
|
||||
t.Fatalf("expected invalid spreadsheet URL error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellDataValidateRejectsNon2DValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
strFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "write 1d array",
|
||||
shortcut: SheetWrite,
|
||||
strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `[1,2]`},
|
||||
},
|
||||
{
|
||||
name: "write object",
|
||||
shortcut: SheetWrite,
|
||||
strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `{"a":1}`},
|
||||
},
|
||||
{
|
||||
name: "append string",
|
||||
shortcut: SheetAppend,
|
||||
strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `"x"`},
|
||||
},
|
||||
{
|
||||
name: "append null",
|
||||
shortcut: SheetAppend,
|
||||
strFlags: map[string]string{"spreadsheet-token": "sht1", "values": `null`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rt := newSheetsTestRuntime(t, tt.strFlags, nil)
|
||||
err := tt.shortcut.Validate(context.Background(), rt)
|
||||
if err == nil || !strings.Contains(err.Error(), "must be a 2D array") {
|
||||
t.Fatalf("expected 2D-array validation error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ func mountAndRunSheets(t *testing.T, s common.Shortcut, args []string, f *cmduti
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
const existingWriteImageTestFile = "./lark_sheets_cell_images.go"
|
||||
|
||||
// ── Validate ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetWriteImageValidateRequiresToken(t *testing.T) {
|
||||
@@ -56,7 +58,7 @@ func TestSheetWriteImageValidateAcceptsURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"url": "https://example.larksuite.com/sheets/shtABC123",
|
||||
"image": "./logo.png",
|
||||
"image": existingWriteImageTestFile,
|
||||
"range": "sheetId!A1:A1",
|
||||
"sheet-id": "",
|
||||
}, nil)
|
||||
@@ -70,7 +72,7 @@ func TestSheetWriteImageValidateAcceptsSpreadsheetToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "shtABC123",
|
||||
"image": "./logo.png",
|
||||
"image": existingWriteImageTestFile,
|
||||
"range": "sheetId!A1:A1",
|
||||
"sheet-id": "",
|
||||
}, nil)
|
||||
@@ -98,7 +100,7 @@ func TestSheetWriteImageValidateAcceptsRelativeRangeWithSheetID(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "shtABC123",
|
||||
"image": "./logo.png",
|
||||
"image": existingWriteImageTestFile,
|
||||
"range": "A1",
|
||||
"sheet-id": "sheet1",
|
||||
}, nil)
|
||||
@@ -126,7 +128,7 @@ func TestSheetWriteImageValidateAcceptsSameCellSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "shtABC123",
|
||||
"image": "./logo.png",
|
||||
"image": existingWriteImageTestFile,
|
||||
"range": "sheet1!A1:A1",
|
||||
"sheet-id": "",
|
||||
}, nil)
|
||||
@@ -219,6 +221,83 @@ func TestSheetWriteImageDryRunWithSheetID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsMissingFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./missing.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "image file not found") {
|
||||
t.Fatalf("expected file-not-found error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cmdutil.TestChdir(t, tmpDir)
|
||||
if err := os.Mkdir("imgdir", 0o755); err != nil {
|
||||
t.Fatalf("Mkdir() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./imgdir",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("expected regular-file error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsAbsolutePath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "/etc/passwd",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe image path") {
|
||||
t.Fatalf("expected unsafe-path error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteImageDryRunRejectsOversizedFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cmdutil.TestChdir(t, tmpDir)
|
||||
|
||||
fh, err := os.Create("huge.png")
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(20*1024*1024 + 1); err != nil {
|
||||
fh.Close()
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err = mountAndRunSheets(t, SheetWriteImage, []string{
|
||||
"+write-image",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--range", "sheet1!A1:A1",
|
||||
"--image", "./huge.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
|
||||
t.Fatalf("expected size error before dry-run planning, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Execute ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSheetWriteImageExecuteSendsJSON(t *testing.T) {
|
||||
323
shortcuts/sheets/lark_sheets_spreadsheet_management.go
Normal file
323
shortcuts/sheets/lark_sheets_spreadsheet_management.go
Normal file
@@ -0,0 +1,323 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetInfo = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+info",
|
||||
Description: "View spreadsheet and sheet information",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token").
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var sheetsData interface{}
|
||||
sheetsResult, sheetsErr := runtime.RawAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(token)), nil, nil)
|
||||
if sheetsErr == nil {
|
||||
if sheetsMap, ok := sheetsResult.(map[string]interface{}); ok {
|
||||
if d, ok := sheetsMap["data"].(map[string]interface{}); ok {
|
||||
sheetsData = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"spreadsheet": spreadsheetData,
|
||||
"sheets": sheetsData,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetCreate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create",
|
||||
Description: "Create a spreadsheet (optional header row and initial data)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "spreadsheet title", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token"},
|
||||
{Name: "headers", Desc: "header row JSON array"},
|
||||
{Name: "data", Desc: "initial data JSON 2D array"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if headersStr := runtime.Str("headers"); headersStr != "" {
|
||||
var headers []interface{}
|
||||
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
|
||||
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
|
||||
}
|
||||
}
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
var rows [][]interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
|
||||
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{"title": runtime.Str("title")}
|
||||
if folderToken := runtime.Str("folder-token"); folderToken != "" {
|
||||
body["folder_token"] = folderToken
|
||||
}
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets").
|
||||
Body(body)
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
title := runtime.Str("title")
|
||||
folderToken := runtime.Str("folder-token")
|
||||
headersStr := runtime.Str("headers")
|
||||
dataStr := runtime.Str("data")
|
||||
var allRows []interface{}
|
||||
|
||||
if headersStr != "" {
|
||||
var headers []interface{}
|
||||
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
|
||||
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
allRows = append(allRows, any(headers))
|
||||
}
|
||||
}
|
||||
|
||||
if dataStr != "" {
|
||||
var rows []interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
|
||||
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
allRows = append(allRows, rows...)
|
||||
}
|
||||
}
|
||||
|
||||
createData := map[string]interface{}{"title": title}
|
||||
if folderToken != "" {
|
||||
createData["folder_token"] = folderToken
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spreadsheet, _ := data["spreadsheet"].(map[string]interface{})
|
||||
token, _ := spreadsheet["spreadsheet_token"].(string)
|
||||
|
||||
if len(allRows) > 0 && token != "" {
|
||||
appendRange, err := getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, 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{}{
|
||||
"range": appendRange,
|
||||
"values": allRows,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"spreadsheet_token": token,
|
||||
"title": title,
|
||||
}
|
||||
url, _ := spreadsheet["url"].(string)
|
||||
if url = strings.TrimSpace(url); url != "" {
|
||||
out["url"] = url
|
||||
} else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetExport = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+export",
|
||||
Description: "Export a spreadsheet (async task polling + optional download)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document:export", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "file-extension", Desc: "export format: xlsx | csv", Required: true, Enum: []string{"xlsx", "csv"}},
|
||||
{Name: "output-path", Desc: "local save path"},
|
||||
{Name: "sheet-id", Desc: "sheet ID (required for CSV)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateSheetManageToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("file-extension") == "csv" && strings.TrimSpace(runtime.Str("sheet-id")) == "" {
|
||||
return common.FlagErrorf("--sheet-id is required when --file-extension is csv")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
body := map[string]interface{}{
|
||||
"token": token,
|
||||
"type": "sheet",
|
||||
"file_extension": runtime.Str("file-extension"),
|
||||
}
|
||||
if sheetID := strings.TrimSpace(runtime.Str("sheet-id")); sheetID != "" {
|
||||
body["sub_id"] = sheetID
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("token", token).Set("ext", runtime.Str("file-extension"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
fileExt := runtime.Str("file-extension")
|
||||
outputPath := runtime.Str("output-path")
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
|
||||
if outputPath != "" {
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
exportData := map[string]interface{}{
|
||||
"token": token,
|
||||
"type": "sheet",
|
||||
"file_extension": fileExt,
|
||||
}
|
||||
if sheetID != "" {
|
||||
exportData["sub_id"] = sheetID
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ticket, _ := data["ticket"].(string)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Waiting for export task to complete...\n")
|
||||
var fileToken string
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
pollResult, err := runtime.RawAPI("GET", "/open-apis/drive/v1/export_tasks/"+ticket, map[string]interface{}{"token": token}, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pollMap, _ := pollResult.(map[string]interface{})
|
||||
pollData, _ := pollMap["data"].(map[string]interface{})
|
||||
pollResult2, _ := pollData["result"].(map[string]interface{})
|
||||
if pollResult2 != nil {
|
||||
ft, _ := pollResult2["file_token"].(string)
|
||||
if ft != "" {
|
||||
fileToken = ft
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fileToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task timed out")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken)
|
||||
|
||||
if outputPath == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"ticket": ticket,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
if savedPath == "" {
|
||||
savedPath = outputPath
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetAddDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+add-dimension",
|
||||
Description: "Add rows or columns at the end of a sheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
length := runtime.Int("length")
|
||||
if length < 1 || length > 5000 {
|
||||
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"length": runtime.Int("length"),
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"length": runtime.Int("length"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetAppend = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+append",
|
||||
Description: "Append rows to a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "append range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "values", Desc: "2D array JSON", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
|
||||
var values interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("values")), &values); err != nil {
|
||||
return common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
appendRange := runtime.Str("range")
|
||||
if appendRange == "" && runtime.Str("sheet-id") != "" {
|
||||
appendRange = runtime.Str("sheet-id")
|
||||
}
|
||||
var values interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("values")), &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}}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
var values interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("values")), &values)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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{}{
|
||||
"range": appendRange,
|
||||
"values": values,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetBatchSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-set-style",
|
||||
Description: "Batch set cell styles for multiple ranges",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
arr, ok := data.([]interface{})
|
||||
if !ok || len(arr) == 0 {
|
||||
return common.FlagErrorf("--data must be a non-empty JSON array")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
var data interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("data")), &data)
|
||||
normalizeBatchStyleRanges(data)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
|
||||
Body(map[string]interface{}{
|
||||
"data": data,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
|
||||
return common.FlagErrorf("--data must be valid JSON: %v", err)
|
||||
}
|
||||
normalizeBatchStyleRanges(data)
|
||||
|
||||
result, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// normalizeBatchStyleRanges mutates each string entry in data[].ranges in place
|
||||
// so the /styles_batch_update endpoint accepts single-cell shorthand.
|
||||
// Entries carrying a sheetId! prefix (e.g. "sheet1!A1") are expanded to
|
||||
// "sheet1!A1:A1"; multi-cell spans pass through unchanged.
|
||||
// A bare single cell without the sheetId! prefix (e.g. "A1") cannot be
|
||||
// expanded because the helper has no sheet-id context (the shortcut exposes
|
||||
// no --sheet-id flag), and the backend would reject the payload anyway —
|
||||
// such entries pass through unchanged. Non-string entries, missing
|
||||
// ranges keys, and non-array top-level inputs are ignored silently.
|
||||
func normalizeBatchStyleRanges(data interface{}) {
|
||||
items, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
entry, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ranges, ok := entry["ranges"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, r := range ranges {
|
||||
if s, ok := r.(string); ok {
|
||||
ranges[i] = normalizePointRange("", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetCreate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create",
|
||||
Description: "Create a spreadsheet (optional header row and initial data)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "spreadsheet title", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token"},
|
||||
{Name: "headers", Desc: "header row JSON array"},
|
||||
{Name: "data", Desc: "initial data JSON 2D array"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if headersStr := runtime.Str("headers"); headersStr != "" {
|
||||
var headers []interface{}
|
||||
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
|
||||
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
|
||||
}
|
||||
}
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
var rows [][]interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
|
||||
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets").
|
||||
Body(map[string]interface{}{"title": runtime.Str("title")})
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
title := runtime.Str("title")
|
||||
folderToken := runtime.Str("folder-token")
|
||||
headersStr := runtime.Str("headers")
|
||||
dataStr := runtime.Str("data")
|
||||
var allRows []interface{}
|
||||
|
||||
if headersStr != "" {
|
||||
var headers []interface{}
|
||||
if err := json.Unmarshal([]byte(headersStr), &headers); err != nil {
|
||||
return common.FlagErrorf("--headers invalid JSON, must be a 1D array")
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
allRows = append(allRows, headers)
|
||||
}
|
||||
}
|
||||
|
||||
if dataStr != "" {
|
||||
var rows []interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &rows); err != nil {
|
||||
return common.FlagErrorf("--data invalid JSON, must be a 2D array")
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
allRows = append(allRows, rows...)
|
||||
}
|
||||
}
|
||||
|
||||
createData := map[string]interface{}{"title": title}
|
||||
if folderToken != "" {
|
||||
createData["folder_token"] = folderToken
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/sheets/v3/spreadsheets", nil, createData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
spreadsheet, _ := data["spreadsheet"].(map[string]interface{})
|
||||
token, _ := spreadsheet["spreadsheet_token"].(string)
|
||||
|
||||
// Write headers and data if provided
|
||||
if len(allRows) > 0 && token != "" {
|
||||
appendRange, err := getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, 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{}{
|
||||
"range": appendRange,
|
||||
"values": allRows,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"spreadsheet_token": token,
|
||||
"title": title,
|
||||
}
|
||||
url, _ := spreadsheet["url"].(string)
|
||||
if url = strings.TrimSpace(url); url != "" {
|
||||
out["url"] = url
|
||||
} else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetDeleteDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-dimension",
|
||||
Description: "Delete rows or columns",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("DELETE",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetExport = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+export",
|
||||
Description: "Export a spreadsheet (async task polling + optional download)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document:export", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "file-extension", Desc: "export format: xlsx | csv", Required: true},
|
||||
{Name: "output-path", Desc: "local save path"},
|
||||
{Name: "sheet-id", Desc: "sheet ID (required for CSV)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(map[string]interface{}{"token": token, "type": "sheet", "file_extension": runtime.Str("file-extension")}).
|
||||
Set("token", token).Set("ext", runtime.Str("file-extension"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
fileExt := runtime.Str("file-extension")
|
||||
outputPath := runtime.Str("output-path")
|
||||
sheetIdFlag := runtime.Str("sheet-id")
|
||||
|
||||
// Early path validation before any API call
|
||||
if outputPath != "" {
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create export task
|
||||
exportData := map[string]interface{}{
|
||||
"token": token,
|
||||
"type": "sheet",
|
||||
"file_extension": fileExt,
|
||||
}
|
||||
if sheetIdFlag != "" {
|
||||
exportData["sub_id"] = sheetIdFlag
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, exportData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ticket, _ := data["ticket"].(string)
|
||||
|
||||
// Poll for completion
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Waiting for export task to complete...\n")
|
||||
var fileToken string
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
pollResult, err := runtime.RawAPI("GET", "/open-apis/drive/v1/export_tasks/"+ticket, map[string]interface{}{"token": token}, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pollMap, _ := pollResult.(map[string]interface{})
|
||||
pollData, _ := pollMap["data"].(map[string]interface{})
|
||||
pollResult2, _ := pollData["result"].(map[string]interface{})
|
||||
if pollResult2 != nil {
|
||||
ft, _ := pollResult2["file_token"].(string)
|
||||
if ft != "" {
|
||||
fileToken = ft
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fileToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "export task timed out")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export complete: file_token=%s\n", fileToken)
|
||||
|
||||
if outputPath == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"ticket": ticket,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// Download
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return output.ErrNetwork("download failed: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
|
||||
savedPath, _ := runtime.ResolveSavePath(outputPath)
|
||||
if savedPath == "" {
|
||||
savedPath = outputPath
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func filterViewBasePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func filterViewItemPath(token, sheetID, filterViewID string) string {
|
||||
return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID))
|
||||
}
|
||||
|
||||
func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
var SheetCreateFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+create-filter-view",
|
||||
Description: "Create a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true},
|
||||
{Name: "filter-view-name", Desc: "display name (max 100 chars)"},
|
||||
{Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{"range": runtime.Str("range")}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{"range": runtime.Str("range")}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-id"); s != "" {
|
||||
body["filter_view_id"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetUpdateFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-filter-view",
|
||||
Description: "Update a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "range", Desc: "new filter range"},
|
||||
{Name: "filter-view-name", Desc: "new display name (max 100 chars)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("range") &&
|
||||
!runtime.Cmd.Flags().Changed("filter-view-name") {
|
||||
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
body := map[string]interface{}{}
|
||||
if s := runtime.Str("range"); s != "" {
|
||||
body["range"] = s
|
||||
}
|
||||
if s := runtime.Str("filter-view-name"); s != "" {
|
||||
body["filter_view_name"] = s
|
||||
}
|
||||
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetListFilterViews = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+list-filter-views",
|
||||
Description: "List all filter views in a sheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetGetFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+get-filter-view",
|
||||
Description: "Get a filter view by ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var SheetDeleteFilterView = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+delete-filter-view",
|
||||
Description: "Delete a filter view",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := validateFilterViewToken(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
|
||||
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetFind = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+find",
|
||||
Description: "Find cells in a spreadsheet",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "find", Desc: "search text", Required: true},
|
||||
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 / C2 with --sheet-id)"},
|
||||
{Name: "ignore-case", Type: "bool", Desc: "case-insensitive search"},
|
||||
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell"},
|
||||
{Name: "search-by-regex", Type: "bool", Desc: "regex search"},
|
||||
{Name: "include-formulas", Type: "bool", Desc: "search formulas"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
sheetIdFlag := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetIdFlag,
|
||||
"match_case": !runtime.Bool("ignore-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizePointRange(sheetIdFlag, runtime.Str("range"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/find").
|
||||
Body(map[string]interface{}{
|
||||
"find": runtime.Str("find"),
|
||||
"find_condition": findCondition,
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", sheetIdFlag).Set("find", runtime.Str("find"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
sheetIdFlag := runtime.Str("sheet-id")
|
||||
findText := runtime.Str("find")
|
||||
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetIdFlag,
|
||||
"match_case": !runtime.Bool("ignore-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizePointRange(sheetIdFlag, runtime.Str("range"))
|
||||
}
|
||||
|
||||
reqData := map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": findText,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/find", validate.EncodePathSegment(token), validate.EncodePathSegment(sheetIdFlag)), nil, reqData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetInfo = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+info",
|
||||
Description: "View spreadsheet and sheet information",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v3/spreadsheets/:token").
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
// Get spreadsheet info
|
||||
spreadsheetData, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s", validate.EncodePathSegment(token)), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get sheets info (best-effort)
|
||||
var sheetsData interface{}
|
||||
sheetsResult, sheetsErr := runtime.RawAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(token)), nil, nil)
|
||||
if sheetsErr == nil {
|
||||
if sheetsMap, ok := sheetsResult.(map[string]interface{}); ok {
|
||||
if d, ok := sheetsMap["data"].(map[string]interface{}); ok {
|
||||
sheetsData = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"spreadsheet": spreadsheetData,
|
||||
"sheets": sheetsData,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetInsertDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+insert-dimension",
|
||||
Description: "Insert rows or columns at a specified position",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true},
|
||||
{Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") <= runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be greater than --start-index")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}
|
||||
if s := runtime.Str("inherit-style"); s != "" {
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range").
|
||||
Body(body).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
}
|
||||
if s := runtime.Str("inherit-style"); s != "" {
|
||||
body["inheritStyle"] = s
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
|
||||
nil, body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// sheetImageParentType is the parent_type accepted by the drive media upload
|
||||
// endpoint for media that will be anchored via +create-float-image.
|
||||
const sheetImageParentType = "sheet_image"
|
||||
|
||||
// SheetMediaUpload uploads a local image to the drive media endpoint against
|
||||
// a spreadsheet and returns the file_token. The token is usable as the
|
||||
// --float-image-token argument to +create-float-image.
|
||||
//
|
||||
// Files up to 20 MB go through /drive/v1/medias/upload_all; larger files are
|
||||
// streamed via upload_prepare / upload_part / upload_finish. This matches the
|
||||
// pattern used by docs +media-upload and drive +import.
|
||||
var SheetMediaUpload = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+media-upload",
|
||||
Description: "Upload a local image for use as a floating image and return the file_token",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document.media:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "file", Desc: "local image path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSheetMediaUploadParent(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
if sheetMediaShouldUseMultipart(runtime.FileIO(), filePath) {
|
||||
dry.Desc("chunked media upload (files > 20MB)").
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry.Set("spreadsheet_token", parentNode)
|
||||
}
|
||||
return dry.Desc("multipart/form-data upload").
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
Set("spreadsheet_token", parentNode)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
parentNode, err := resolveSheetMediaUploadParent(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := runtime.Str("file")
|
||||
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s) -> spreadsheet %s\n",
|
||||
fileName, common.FormatSize(stat.Size()), common.MaskToken(parentNode))
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
fileToken, err := uploadSheetMediaFile(runtime, filePath, fileName, stat.Size(), parentNode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"size": stat.Size(),
|
||||
"spreadsheet_token": parentNode,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// resolveSheetMediaUploadParent returns the spreadsheet token to use as parent_node,
|
||||
// accepting either --url or --spreadsheet-token.
|
||||
func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, error) {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if u := runtime.Str("url"); u != "" {
|
||||
if parsed := extractSpreadsheetToken(u); parsed != "" {
|
||||
token = parsed
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// uploadSheetMediaFile routes to the single-part or multipart upload path based
|
||||
// on file size. Always uses parent_type=sheet_image so the returned token can
|
||||
// be consumed by +create-float-image.
|
||||
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
pn := parentNode
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: &pn,
|
||||
})
|
||||
}
|
||||
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
|
||||
// sheetMediaShouldUseMultipart mirrors docMediaShouldUseMultipart: dry-run uses
|
||||
// local stat as a best-effort planning hint. Execute re-validates before
|
||||
// choosing the actual upload path.
|
||||
func sheetMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
|
||||
info, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetMergeCells = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+merge-cells",
|
||||
Description: "Merge cells in a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
{Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells").
|
||||
Body(map[string]interface{}{
|
||||
"range": r,
|
||||
"mergeType": runtime.Str("merge-type"),
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"range": r,
|
||||
"mergeType": runtime.Str("merge-type"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetMoveDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+move-dimension",
|
||||
Description: "Move rows or columns to a new position",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true},
|
||||
{Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if runtime.Int("start-index") < 0 {
|
||||
return common.FlagErrorf("--start-index must be >= 0")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
if runtime.Int("destination-index") < 0 {
|
||||
return common.FlagErrorf("--destination-index must be >= 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension").
|
||||
Body(map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"major_dimension": runtime.Str("dimension"),
|
||||
"start_index": runtime.Int("start-index"),
|
||||
"end_index": runtime.Int("end-index"),
|
||||
},
|
||||
"destination_index": runtime.Int("destination-index"),
|
||||
}).
|
||||
Set("token", token).
|
||||
Set("sheet_id", runtime.Str("sheet-id"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(runtime.Str("sheet-id")),
|
||||
),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"major_dimension": runtime.Str("dimension"),
|
||||
"start_index": runtime.Int("start-index"),
|
||||
"end_index": runtime.Int("end-index"),
|
||||
},
|
||||
"destination_index": runtime.Int("destination-index"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetRead = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+read",
|
||||
Description: "Read spreadsheet cell values",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "read range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "value-render-option", Desc: "render option: ToString|FormattedValue|Formula|UnformattedValue"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
readRange := runtime.Str("range")
|
||||
if readRange == "" && runtime.Str("sheet-id") != "" {
|
||||
readRange = runtime.Str("sheet-id")
|
||||
}
|
||||
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)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
|
||||
|
||||
params := map[string]interface{}{}
|
||||
renderOption := runtime.Str("value-render-option")
|
||||
if renderOption != "" {
|
||||
params["valueRenderOption"] = renderOption
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", validate.EncodePathSegment(token), validate.EncodePathSegment(readRange)), params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetReplace = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+replace",
|
||||
Description: "Find and replace cell values in a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "find", Desc: "search text or regex pattern", Required: true},
|
||||
{Name: "replacement", Desc: "replacement text", Required: true},
|
||||
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 with --sheet-id)"},
|
||||
{Name: "match-case", Type: "bool", Desc: "case-sensitive search"},
|
||||
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"},
|
||||
{Name: "search-by-regex", Type: "bool", Desc: "use regex search"},
|
||||
{Name: "include-formulas", Type: "bool", Desc: "search in formulas"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
if r := runtime.Str("range"); r != "" {
|
||||
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
|
||||
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": runtime.Bool("match-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace").
|
||||
Body(map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": runtime.Str("find"),
|
||||
"replacement": runtime.Str("replacement"),
|
||||
}).
|
||||
Set("token", token).Set("sheet_id", sheetID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
sheetID := runtime.Str("sheet-id")
|
||||
findCondition := map[string]interface{}{
|
||||
"range": sheetID,
|
||||
"match_case": runtime.Bool("match-case"),
|
||||
"match_entire_cell": runtime.Bool("match-entire-cell"),
|
||||
"search_by_regex": runtime.Bool("search-by-regex"),
|
||||
"include_formulas": runtime.Bool("include-formulas"),
|
||||
}
|
||||
if runtime.Str("range") != "" {
|
||||
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
|
||||
validate.EncodePathSegment(token),
|
||||
validate.EncodePathSegment(sheetID),
|
||||
),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"find_condition": findCondition,
|
||||
"find": runtime.Str("find"),
|
||||
"replacement": runtime.Str("replacement"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+set-style",
|
||||
Description: "Set cell style for a range",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
{Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
if _, ok := style.(map[string]interface{}); !ok {
|
||||
return common.FlagErrorf("--style must be a JSON object, got %T", style)
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("style")), &style)
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
|
||||
Body(map[string]interface{}{
|
||||
"appendStyle": map[string]interface{}{
|
||||
"range": r,
|
||||
"style": style,
|
||||
},
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
var style interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
|
||||
return common.FlagErrorf("--style must be valid JSON: %v", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"appendStyle": map[string]interface{}{
|
||||
"range": r,
|
||||
"style": style,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetUnmergeCells = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+unmerge-cells",
|
||||
Description: "Unmerge (split) cells in a spreadsheet",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
|
||||
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells").
|
||||
Body(map[string]interface{}{
|
||||
"range": r,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
|
||||
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"range": r,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetUpdateDimension = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+update-dimension",
|
||||
Description: "Update row or column properties (visibility, size)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
|
||||
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
|
||||
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
|
||||
{Name: "visible", Type: "bool", Desc: "true to show, false to hide"},
|
||||
{Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
if runtime.Int("start-index") < 1 {
|
||||
return common.FlagErrorf("--start-index must be >= 1")
|
||||
}
|
||||
if runtime.Int("end-index") < runtime.Int("start-index") {
|
||||
return common.FlagErrorf("--end-index must be >= --start-index")
|
||||
}
|
||||
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
|
||||
return common.FlagErrorf("--fixed-size must be >= 1")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
props := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("visible") {
|
||||
props["visible"] = runtime.Bool("visible")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
props["fixedSize"] = runtime.Int("fixed-size")
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
|
||||
Body(map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
"dimensionProperties": props,
|
||||
}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
props := map[string]interface{}{}
|
||||
if runtime.Cmd.Flags().Changed("visible") {
|
||||
props["visible"] = runtime.Bool("visible")
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("fixed-size") {
|
||||
props["fixedSize"] = runtime.Int("fixed-size")
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("PUT",
|
||||
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"dimension": map[string]interface{}{
|
||||
"sheetId": runtime.Str("sheet-id"),
|
||||
"majorDimension": runtime.Str("dimension"),
|
||||
"startIndex": runtime.Int("start-index"),
|
||||
"endIndex": runtime.Int("end-index"),
|
||||
},
|
||||
"dimensionProperties": props,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SheetWrite = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+write",
|
||||
Description: "Write to spreadsheet cells (overwrite mode)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "spreadsheet URL"},
|
||||
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
|
||||
{Name: "range", Desc: "write range (<sheetId>!A1:D10, A1:D10 with --sheet-id, or a single cell like C2)"},
|
||||
{Name: "sheet-id", Desc: "sheet ID"},
|
||||
{Name: "values", Desc: "2D array JSON", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
if token == "" {
|
||||
return common.FlagErrorf("specify --url or --spreadsheet-token")
|
||||
}
|
||||
|
||||
var values interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("values")), &values); err != nil {
|
||||
return common.FlagErrorf("--values invalid JSON, must be a 2D array")
|
||||
}
|
||||
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
writeRange := runtime.Str("range")
|
||||
if writeRange == "" && runtime.Str("sheet-id") != "" {
|
||||
writeRange = runtime.Str("sheet-id")
|
||||
}
|
||||
var values interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("values")), &values)
|
||||
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}}).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("spreadsheet-token")
|
||||
if runtime.Str("url") != "" {
|
||||
token = extractSpreadsheetToken(runtime.Str("url"))
|
||||
}
|
||||
|
||||
var values interface{}
|
||||
json.Unmarshal([]byte(runtime.Str("values")), &values)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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{}{
|
||||
"range": writeRange,
|
||||
"values": values,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -8,24 +8,41 @@ 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,
|
||||
SheetWriteImage,
|
||||
SheetAppend,
|
||||
SheetFind,
|
||||
SheetCreate,
|
||||
SheetExport,
|
||||
SheetMergeCells,
|
||||
SheetUnmergeCells,
|
||||
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,
|
||||
@@ -36,10 +53,14 @@ func Shortcuts() []common.Shortcut {
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
|
||||
// Dropdown
|
||||
SheetSetDropdown,
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
|
||||
// Float images
|
||||
SheetMediaUpload,
|
||||
SheetCreateFloatImage,
|
||||
SheetUpdateFloatImage,
|
||||
|
||||
@@ -92,6 +92,14 @@ func extractTasklistGuid(input string) string {
|
||||
return input
|
||||
}
|
||||
|
||||
// extractTaskGuid extracts a task GUID from either a raw GUID or a Feishu task
|
||||
// applink URL (e.g. ".../client/todo/task?guid=..."). The URL query parameter
|
||||
// is always named "guid" for both tasks and tasklists, so we delegate to the
|
||||
// shared parsing logic.
|
||||
func extractTaskGuid(input string) string {
|
||||
return extractTasklistGuid(input)
|
||||
}
|
||||
|
||||
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := make(map[string]interface{})
|
||||
|
||||
@@ -251,6 +259,7 @@ func Shortcuts() []common.Shortcut {
|
||||
GetRelatedTasks,
|
||||
SearchTask,
|
||||
SubscribeTaskEvent,
|
||||
UploadAttachmentTask,
|
||||
CreateTasklist,
|
||||
SearchTasklist,
|
||||
AddTaskToTasklist,
|
||||
|
||||
237
shortcuts/task/task_upload_attachment.go
Normal file
237
shortcuts/task/task_upload_attachment.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// taskAttachmentUploadMaxSize is the upper bound on a single attachment upload
|
||||
// to the Task service (50MB, as documented by the open API).
|
||||
const taskAttachmentUploadMaxSize int64 = 50 * 1024 * 1024
|
||||
|
||||
// taskAttachmentUploadPath is the Task open-api endpoint that accepts a single
|
||||
// multipart/form-data upload per call.
|
||||
const taskAttachmentUploadPath = "/open-apis/task/v2/attachments/upload"
|
||||
|
||||
// defaultTaskAttachmentResourceType is used when the caller does not pass an
|
||||
// explicit --resource-type flag. Task is the only resource type documented for
|
||||
// this endpoint today, but the flag is kept open so that future resource types
|
||||
// can be targeted without a client upgrade.
|
||||
const defaultTaskAttachmentResourceType = "task"
|
||||
|
||||
// UploadAttachmentTask uploads a single local file as an attachment to a task
|
||||
// (or any other resource type accepted by the Task attachment endpoint).
|
||||
var UploadAttachmentTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+upload-attachment",
|
||||
Description: "upload a local file as an attachment to a task; use --resource-type=task_delivery when uploading to task agents",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:attachment:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "resource-id", Desc: "task guid (or task applink URL)", Required: true},
|
||||
{Name: "file", Desc: "local file path (single file, <= 50MB)", Required: true},
|
||||
{Name: "resource-type", Desc: "owning resource type (default: task); use task_delivery when uploading to task agents", Default: defaultTaskAttachmentResourceType},
|
||||
{Name: "user-id-type", Desc: "user id type (default: open_id)", Default: "open_id"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
resourceType := runtime.Str("resource-type")
|
||||
if resourceType == "" {
|
||||
resourceType = defaultTaskAttachmentResourceType
|
||||
}
|
||||
resourceID := extractTaskGuid(runtime.Str("resource-id"))
|
||||
filePath := runtime.Str("file")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
if userIDType == "" {
|
||||
userIDType = "open_id"
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
POST(taskAttachmentUploadPath).
|
||||
Params(map[string]interface{}{"user_id_type": userIDType}).
|
||||
Body(map[string]interface{}{
|
||||
"resource_type": resourceType,
|
||||
"resource_id": resourceID,
|
||||
"file": map[string]string{
|
||||
"field": "file",
|
||||
"path": filePath,
|
||||
"name": filepath.Base(filePath),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
resourceType := runtime.Str("resource-type")
|
||||
if resourceType == "" {
|
||||
resourceType = defaultTaskAttachmentResourceType
|
||||
}
|
||||
resourceID := extractTaskGuid(runtime.Str("resource-id"))
|
||||
filePath := runtime.Str("file")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
if userIDType == "" {
|
||||
userIDType = "open_id"
|
||||
}
|
||||
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
stat, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
if stat.Size() > taskAttachmentUploadMaxSize {
|
||||
return output.ErrValidation(
|
||||
"attachment %s exceeds the 50MB per-file limit",
|
||||
common.FormatSize(stat.Size()),
|
||||
)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
// Observability: input parsed.
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"[+upload-attachment] input parsed: resource_type=%s resource_id=%s file=%s size=%s\n",
|
||||
resourceType, resourceID, filePath, common.FormatSize(stat.Size()),
|
||||
)
|
||||
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, "cannot open file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Build the multipart body manually so the real filename is preserved
|
||||
// in the `file` part's Content-Disposition. The SDK's Formdata.AddFile
|
||||
// hardcodes the filename to "unknown-file" (see oapi-sdk-go
|
||||
// core/reqtranslator.go), which is what was showing up in the Task UI.
|
||||
var bodyBuf bytes.Buffer
|
||||
mw := common.NewMultipartWriter(&bodyBuf)
|
||||
if err := mw.WriteField("resource_type", resourceType); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
|
||||
}
|
||||
if err := mw.WriteField("resource_id", resourceID); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
|
||||
}
|
||||
filePart, err := mw.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
|
||||
}
|
||||
if _, err := io.Copy(filePart, f); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "write file to multipart body: %s", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "finalize multipart body: %s", err)
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
|
||||
// Observability: HTTP call about to start.
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http call: POST %s user_id_type=%s\n",
|
||||
taskAttachmentUploadPath, userIDType,
|
||||
)
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
httpResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "POST",
|
||||
ApiPath: taskAttachmentUploadPath,
|
||||
QueryParams: queryParams,
|
||||
Body: &bodyBuf,
|
||||
}, client.WithHeaders(headers))
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: error=%v\n", err)
|
||||
return err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
rawBody, readErr := io.ReadAll(httpResp.Body)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: read_error=%v\n", readErr)
|
||||
return WrapTaskError(ErrCodeTaskInternalError,
|
||||
fmt.Sprintf("failed to read response: %v", readErr),
|
||||
"upload task attachment")
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if parseErr := json.Unmarshal(rawBody, &result); parseErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: parse_error=%v\n", parseErr)
|
||||
return WrapTaskError(ErrCodeTaskInternalError,
|
||||
fmt.Sprintf("failed to parse response: %v", parseErr),
|
||||
"upload task attachment")
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResult(result, nil, "upload task attachment")
|
||||
if err != nil {
|
||||
code, _ := result["code"]
|
||||
msg, _ := result["msg"].(string)
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: code=%v msg=%q error=%v\n",
|
||||
code, msg, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// The Task attachment upload endpoint returns `data.items` containing
|
||||
// the freshly created attachment records. Since this shortcut uploads
|
||||
// exactly one file per call, we surface the single record directly as
|
||||
// the output envelope — all fields returned by the API (guid, name,
|
||||
// size, url, resource_type, uploader, ...) are preserved verbatim.
|
||||
items, _ := data["items"].([]interface{})
|
||||
var first map[string]interface{}
|
||||
if len(items) > 0 {
|
||||
first, _ = items[0].(map[string]interface{})
|
||||
}
|
||||
if first == nil {
|
||||
first = map[string]interface{}{}
|
||||
}
|
||||
guid, _ := first["guid"].(string)
|
||||
|
||||
code, _ := result["code"]
|
||||
msg, _ := result["msg"].(string)
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: code=%v msg=%q attachment_guid=%s\n",
|
||||
code, msg, guid)
|
||||
|
||||
runtime.OutFormat(first, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Attachment uploaded successfully!\n")
|
||||
fmt.Fprintf(w, "Resource: %s/%s\n", resourceType, resourceID)
|
||||
name, _ := first["name"].(string)
|
||||
if name == "" {
|
||||
name = fileName
|
||||
}
|
||||
fmt.Fprintf(w, "File: %s (%s)\n", name, common.FormatSize(stat.Size()))
|
||||
if guid != "" {
|
||||
fmt.Fprintf(w, "Attachment GUID: %s\n", guid)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
449
shortcuts/task/task_upload_attachment_test.go
Normal file
449
shortcuts/task/task_upload_attachment_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// writeTestFile creates a file at name (relative to cwd) with size bytes of
|
||||
// ASCII data and returns the relative path it wrote.
|
||||
func writeTestFile(t *testing.T, name string, size int) string {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(name, bytes.Repeat([]byte("a"), size), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error: %v", name, err)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// writeSparseTestFile produces a sparse file of the requested size without
|
||||
// allocating real disk space, useful for exercising the 50MB validation path.
|
||||
func writeSparseTestFile(t *testing.T, name string, size int64) string {
|
||||
t.Helper()
|
||||
fh, err := os.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Truncate(size); err != nil {
|
||||
t.Fatalf("Truncate(%q, %d) error: %v", name, size, err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close(%q) error: %v", name, err)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_Success(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
format string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "pretty format",
|
||||
format: "pretty",
|
||||
contains: []string{
|
||||
"✅ Attachment uploaded successfully!",
|
||||
"Attachment GUID: att-guid-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "json format",
|
||||
format: "json",
|
||||
contains: []string{
|
||||
`"guid": "att-guid-1"`,
|
||||
`"name": "note.txt"`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, stderr, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 12)
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "att-guid-1",
|
||||
"name": "note.txt",
|
||||
"size": 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
args := []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", tt.format,
|
||||
}
|
||||
if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Normalize JSON whitespace so that both compact and indented forms match.
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.contains {
|
||||
if !strings.Contains(outNorm, want) && !strings.Contains(out, want) {
|
||||
t.Errorf("stdout missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify multipart body structure.
|
||||
body := decodeTaskAttachmentMultipart(t, uploadStub)
|
||||
if got := body.Fields["resource_type"]; got != "task" {
|
||||
t.Errorf("resource_type = %q, want %q", got, "task")
|
||||
}
|
||||
if got := body.Fields["resource_id"]; got != "task-guid-123" {
|
||||
t.Errorf("resource_id = %q, want %q", got, "task-guid-123")
|
||||
}
|
||||
if got, ok := body.Files["file"]; !ok {
|
||||
t.Errorf("multipart missing file part")
|
||||
} else if len(got) != 12 {
|
||||
t.Errorf("file size = %d, want 12", len(got))
|
||||
}
|
||||
if got := body.FileNames["file"]; got != "note.txt" {
|
||||
t.Errorf("multipart file filename = %q, want %q", got, "note.txt")
|
||||
}
|
||||
|
||||
// Verify key observability logs on stderr.
|
||||
errOut := stderr.String()
|
||||
for _, log := range []string{
|
||||
"input parsed",
|
||||
"http call: POST /open-apis/task/v2/attachments/upload",
|
||||
"http response",
|
||||
"att-guid-1",
|
||||
} {
|
||||
if !strings.Contains(errOut, log) {
|
||||
t.Errorf("stderr missing log %q; got:\n%s", log, errOut)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_ExplicitResourceTypePassthrough(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 5)
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"guid": "att-guid-2"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--resource-type", "custom_type",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeTaskAttachmentMultipart(t, uploadStub)
|
||||
if got := body.Fields["resource_type"]; got != "custom_type" {
|
||||
t.Fatalf("resource_type = %q, want custom_type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_ResourceIDFromApplink(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 5)
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"guid": "att-guid-3"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
applink := "https://applink.feishu.cn/client/todo/task?guid=task-from-url"
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", applink,
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeTaskAttachmentMultipart(t, uploadStub)
|
||||
if got := body.Fields["resource_id"]; got != "task-from-url" {
|
||||
t.Fatalf("resource_id = %q, want task-from-url", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_SizeLimit(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// 50MB + 1 byte; no HTTP stub registered — we must fail before any call.
|
||||
filePath := writeSparseTestFile(t, "big.bin", 50*1024*1024+1)
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "50MB") {
|
||||
t.Fatalf("error message should mention 50MB limit, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_FileMissing(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", "does-not-exist.bin",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_APIError(t *testing.T) {
|
||||
f, stdout, stderr, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 3)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": ErrCodeTaskPermissionDenied,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Code != ErrCodeTaskPermissionDenied {
|
||||
t.Fatalf("expected task permission denied code %d, got: %+v", ErrCodeTaskPermissionDenied, exitErr.Detail)
|
||||
}
|
||||
|
||||
// Key-path log should still be emitted on failure.
|
||||
errOut := stderr.String()
|
||||
for _, log := range []string{"input parsed", "http call", "http response"} {
|
||||
if !strings.Contains(errOut, log) {
|
||||
t.Errorf("stderr missing failure log %q; got:\n%s", log, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_DryRun(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
extraArgs []string
|
||||
wantResourceType string
|
||||
}{
|
||||
{
|
||||
name: "default resource type",
|
||||
extraArgs: nil,
|
||||
wantResourceType: "task",
|
||||
},
|
||||
{
|
||||
name: "explicit resource type",
|
||||
extraArgs: []string{"--resource-type", "custom_type"},
|
||||
wantResourceType: "custom_type",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
args := []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", "./some.pdf",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
"--dry-run",
|
||||
}
|
||||
args = append(args, tt.extraArgs...)
|
||||
if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
var dry map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &dry); err != nil {
|
||||
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
calls, _ := dry["api"].([]interface{})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 api call in dry-run, got %d: %v", len(calls), calls)
|
||||
}
|
||||
call := calls[0].(map[string]interface{})
|
||||
if got := call["method"]; got != "POST" {
|
||||
t.Fatalf("method = %v, want POST", got)
|
||||
}
|
||||
if got := call["url"]; got != "/open-apis/task/v2/attachments/upload" {
|
||||
t.Fatalf("url = %v, want upload path", got)
|
||||
}
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["user_id_type"]; got != "open_id" {
|
||||
t.Fatalf("params.user_id_type = %v, want open_id", got)
|
||||
}
|
||||
body := call["body"].(map[string]interface{})
|
||||
if got := body["resource_type"]; got != tt.wantResourceType {
|
||||
t.Fatalf("resource_type = %v, want %v", got, tt.wantResourceType)
|
||||
}
|
||||
if got := body["resource_id"]; got != "task-guid-123" {
|
||||
t.Fatalf("resource_id = %v, want task-guid-123", got)
|
||||
}
|
||||
fileDesc := body["file"].(map[string]interface{})
|
||||
if got := fileDesc["field"]; got != "file" {
|
||||
t.Fatalf("file.field = %v, want file", got)
|
||||
}
|
||||
if got := fileDesc["path"]; got != "./some.pdf" {
|
||||
t.Fatalf("file.path = %v, want ./some.pdf", got)
|
||||
}
|
||||
if got := fileDesc["name"]; got != "some.pdf" {
|
||||
t.Fatalf("file.name = %v, want some.pdf", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── multipart body helper ──────────────────────────────────────────────────
|
||||
|
||||
type capturedAttachmentMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
FileNames map[string]string
|
||||
}
|
||||
|
||||
func decodeTaskAttachmentMultipart(t *testing.T, stub *httpmock.Stub) capturedAttachmentMultipart {
|
||||
t.Helper()
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content-type %q: %v", contentType, err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedAttachmentMultipart{
|
||||
Fields: map[string]string{},
|
||||
Files: map[string][]byte{},
|
||||
FileNames: map[string]string{},
|
||||
}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart part: %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart data: %v", err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = data
|
||||
body.FileNames[part.FormName()] = part.FileName()
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = string(data)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -162,4 +162,4 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
**限制**:
|
||||
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号
|
||||
- 下拉列表需**先通过 `+set-dropdown` 配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。值中的字符串不能包含逗号
|
||||
@@ -95,7 +95,7 @@ metadata:
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+field-list / +field-get` | 列出字段结构,或获取单个字段详情 | [`lark-base-field-list.md`](references/lark-base-field-list.md)、[`lark-base-field-get.md`](references/lark-base-field-get.md) | 写记录、写字段、做分析前常先读 `+field-list`;`+field-list` 只能串行执行;`+field-get` 适合删除/更新前确认目标 |
|
||||
| `+field-create / +field-update / +field-delete` | 创建、更新或删除普通字段 | [`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-field-delete.md`](references/lark-base-field-delete.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 写字段前先看字段属性规范;如果类型是 `formula / lookup`,先转去读对应 guide;删除时用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+field-create / +field-update / +field-delete` | 创建、更新或删除普通字段 | [`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-field-delete.md`](references/lark-base-field-delete.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 写字段前先看字段属性规范;如果涉及类型转换,直接按 `+field-update` 中的字段类型变更规则执行,只在安全白名单内考虑原地转换;如果类型是 `formula / lookup`,先转去读对应 guide;删除时用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+field-search-options` | 查询字段可选项 | [`lark-base-field-search-options.md`](references/lark-base-field-search-options.md) | 适合单选/多选等选项型字段 |
|
||||
|
||||
#### 2.3.3 Record 子模块
|
||||
@@ -104,11 +104,12 @@ metadata:
|
||||
|
||||
| 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 |
|
||||
|------|------------------|----------------|----------|
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query` |
|
||||
| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 |
|
||||
| `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 |
|
||||
| `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 |
|
||||
| `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) |
|
||||
| `+record-delete / +record-history-list` | 删除记录,或查询某条记录的变更历史 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md)、[`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 删除时用户已明确目标可直接执行并带 `--yes`;历史查询按 `table-id + record-id`,不支持整表扫描;`+record-history-list` 只能串行执行 |
|
||||
| `+record-delete` | 删除一条或多条记录 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md) | 删除多条时重复传 `--record-id` 指定多个记录;用户已明确目标可直接执行并带 `--yes` |
|
||||
| `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 |
|
||||
| `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 |
|
||||
|
||||
#### 2.3.4 View 子模块
|
||||
|
||||
@@ -81,6 +81,75 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
|
||||
1. 建议先用 `+field-get` 拉现状,再做最小化修改。
|
||||
2. `formula/lookup` 类型更新前先阅读对应指南。
|
||||
3. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。
|
||||
|
||||
## 字段类型变更规则
|
||||
|
||||
字段类型变更采用白名单机制:**只允许白名单转换**;未命中白名单时,**不建议用 CLI 转换字段类型** 除非用户明确知道风险并同意。
|
||||
|
||||
### 允许直接转换 type
|
||||
|
||||
先 `+field-get` / `+field-list` 看结构,再抽样读值;只有命中以下规则时,转换才是比较安全的。
|
||||
|
||||
#### 相对安全
|
||||
|
||||
| 目标类型 | 允许的源类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `text` | `number`、`select`、`datetime`、`created_at`、`updated_at`、`location`、`auto_number`、`checkbox` | 保留字符串表示;丢失原类型语义和结构化能力 |
|
||||
| `number` | `text`、`number`、`datetime`、`created_at`、`updated_at`、`checkbox` | 保留可解析的数字值;无法解析的值会变空,原文本格式会丢失 |
|
||||
| `datetime` | `text`、`number`、`datetime`、`created_at`、`updated_at` | 保留可解析的时间字符串和时间戳;无法解析的值会变空,原文本格式会丢失 |
|
||||
| `select` | `text -> select`、`number -> select`、`single select -> multi select` | 只有完全匹配目标选项名的值会转成对应选项;没匹配上的值会被丢弃 |
|
||||
|
||||
#### 可执行但会截断 / 重算
|
||||
|
||||
- `select(multi) -> select(single)`: 只保留第一个值,其余值会被丢弃。
|
||||
- `user(multi) -> user(single)`: 只保留第一个人员,其余值会被丢弃。
|
||||
- `group_chat(multi) -> group_chat(single)`: 只保留第一个群,其余值会被丢弃。
|
||||
|
||||
#### 无状态字段可直接转换
|
||||
|
||||
- `created_at`、`created_by`、`updated_at`、`updated_by`、`formula`、`lookup`: 这类字段值由系统或计算逻辑生成,不承载独立存储数据;可以执行类型转换,不必担心破坏原始记录值,但仍要做下游读回验证。
|
||||
|
||||
### 一律不要用 CLI 转换
|
||||
|
||||
以下场景全部视为黑名单;默认要求用户改到 Web 页面手动完成,或改走“新建字段 + 数据迁移”。
|
||||
|
||||
- `any -> checkbox`
|
||||
- `any -> user`
|
||||
- `any -> group_chat`
|
||||
- `any -> attachment`
|
||||
- `any -> location`
|
||||
- `link` 类型变更
|
||||
- 任意涉及动态 / 静态选项来源切换的 `select` 类型变更
|
||||
|
||||
### 可例外继续执行的场景
|
||||
|
||||
只有在**整列数据丢失可接受**时,才允许对黑名单场景例外执行。
|
||||
|
||||
- `EmptyColumn`: 该列为空
|
||||
- `FreshTableInit`: 新建空表初始化
|
||||
- `PrimaryFieldBootstrap`: 主列不能删,只能更新完成初始化
|
||||
- `ExplicitLossAccepted`: 用户明确接受整列数据丢失
|
||||
|
||||
不满足以上条件时,不要转换。
|
||||
|
||||
### 非白名单场景如何处理
|
||||
|
||||
- 命中白名单时:建议直接原地转换,再做读回验证。
|
||||
- 未命中白名单时:先询问用户是否仍要执行转换,并明确说明风险:
|
||||
- 无状态字段除外;这类字段可以直接转换
|
||||
- 可能整列变空
|
||||
- 可能只保留第一个值
|
||||
- 可能只保留字符串表示,丢失原类型语义和结构化能力
|
||||
- 可能影响视图 / 筛选 / 排序 / 公式 / lookup / 写入引用
|
||||
- 如果用户不接受风险:不要执行转换。
|
||||
|
||||
### 完成态验证
|
||||
|
||||
- `FieldReadback`: 读回字段结构,确认 `type` / `multiple` / `style` / `options`
|
||||
- `ValueReadback`: 抽样读回转换后的单元格值
|
||||
- `DownstreamReadback`: 若涉及看板 / 分组 / 排序 / lookup / 公式,继续读回结果
|
||||
- `CompletionRule`: 结构、值、下游能力都正确,才能回复“已完成”
|
||||
|
||||
## 坑点
|
||||
|
||||
|
||||
@@ -20,3 +20,4 @@ field 相关命令索引。
|
||||
- 聚合页只保留目录职责;每个命令的详细说明请进入对应单命令文档。
|
||||
- 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。
|
||||
- 写字段 JSON 前优先阅读 [lark-base-shortcut-field-properties.md](lark-base-shortcut-field-properties.md)。
|
||||
- 涉及字段类型转换时,直接阅读 [lark-base-field-update.md](lark-base-field-update.md) 中的“字段类型变更规则”。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
删除一条记录。
|
||||
删除一条或多条记录。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
@@ -14,25 +14,36 @@ lark-cli base +record-delete \
|
||||
--yes
|
||||
```
|
||||
|
||||
```bash
|
||||
lark-cli base +record-delete \
|
||||
--base-token app_xxx \
|
||||
--table-id tbl_xxx \
|
||||
--record-id rec_001 \
|
||||
--record-id rec_002 \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--record-id <id>` | 是 | 记录 ID |
|
||||
| `--record-id <id>` | 否 | 与 `--json` 二选一;记录 ID,可重复使用;这是主推荐用法 |
|
||||
| `--json <object>` | 否 | 与 `--record-id` 二选一;脚本/代理场景可传 `{"record_id_list":["rec_xxx"]}` |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
|
||||
```
|
||||
DELETE /open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id
|
||||
```http
|
||||
POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete
|
||||
```
|
||||
|
||||
## 返回重点
|
||||
|
||||
- 返回 `deleted: true` 和 `record_id`。
|
||||
- CLI 内部统一通过 `batch_delete` 删除记录;单个和多个 `--record-id` 使用相同的批量删除输出形态。
|
||||
- 成功时直接返回接口 `data` 字段内容,通常包含 `record_id_list`。
|
||||
|
||||
## 工作流
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ record 相关命令索引。
|
||||
| [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 |
|
||||
| [lark-base-record-upload-attachment.md](lark-base-record-upload-attachment.md) | `+record-upload-attachment` | 上传本地文件到附件字段并更新记录 |
|
||||
| [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md) | `lark-cli docs +media-download` | 下载 Base 附件到本地(附件的 `file_token` 来自 `+record-get` 的附件字段) |
|
||||
| [lark-base-record-delete.md](lark-base-record-delete.md) | `+record-delete` | 删除记录 |
|
||||
| [lark-base-record-delete.md](lark-base-record-delete.md) | `+record-delete` | 删除一条或多条记录 |
|
||||
| [lark-base-record-share-link-create.md](lark-base-record-share-link-create.md) | `+record-share-link-create` | 生成记录分享链接(支持单条或批量,最多 100 条)|
|
||||
|
||||
## 说明
|
||||
@@ -23,6 +23,7 @@ record 相关命令索引。
|
||||
- 聚合页只保留目录职责;写入、删除、历史等命令的详细说明请进入对应单命令文档。
|
||||
- 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。
|
||||
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。
|
||||
- `+record-get` 支持重复 `--record-id` 或 `--json '{"record_id_list":[...]}'` 批量读取;也支持重复传参 `--field-id` 裁剪返回字段,避免返回全字段。
|
||||
- 写记录 JSON 前优先阅读 [lark-base-cell-value.md](lark-base-cell-value.md)。
|
||||
- 本地文件写入附件字段时,必须使用 `+record-upload-attachment`。
|
||||
- 从附件字段下载文件时,用 `lark-cli docs +media-download --token <file_token> --output <path>`,用法见 [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md)。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user