mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
48 Commits
feat/im-co
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b449614be9 | ||
|
|
8e2f517827 | ||
|
|
f6353368d7 | ||
|
|
c4270566ba | ||
|
|
75e8063bf2 | ||
|
|
ae8b1a4cf3 | ||
|
|
01ae4cc521 | ||
|
|
99ac5d6906 | ||
|
|
fd97e65b55 | ||
|
|
4e2c6a2096 | ||
|
|
f619e34d38 | ||
|
|
3e1623c631 | ||
|
|
b2551fd70b | ||
|
|
50b1f54bfa | ||
|
|
f8ed143bde | ||
|
|
92c5b0f26d | ||
|
|
47f4fda1d3 | ||
|
|
003d0f42f8 | ||
|
|
7866801115 | ||
|
|
3624499bcb | ||
|
|
13d4350557 | ||
|
|
7946e5c81d | ||
|
|
5cf09ecfda | ||
|
|
41692b7041 | ||
|
|
daeab10755 | ||
|
|
6402cb6a3a | ||
|
|
8491775659 | ||
|
|
f5e0d14ca9 | ||
|
|
240e523dbf | ||
|
|
9223754af1 | ||
|
|
3788b6f601 | ||
|
|
0e95848dd7 | ||
|
|
e41e36e1d9 | ||
|
|
a5032bbb55 | ||
|
|
d8f6154e2f | ||
|
|
d219d61be0 | ||
|
|
f79908483d | ||
|
|
52b5910fb1 | ||
|
|
0b59556207 | ||
|
|
91743bba99 | ||
|
|
ca7135f582 | ||
|
|
7b58ba1b1d | ||
|
|
765b097d44 | ||
|
|
4a5e2c519a | ||
|
|
67fc870582 | ||
|
|
af8e027269 | ||
|
|
2efadec335 | ||
|
|
5fb70d326a |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.81] - 2026-07-31
|
||||
|
||||
### Features
|
||||
|
||||
- support visible_rule for form questions (#1891)
|
||||
- **contact**: add bot search shortcut (#2083)
|
||||
- add SXSD schema validation to Slides lint (#2103)
|
||||
- **drive**: add comment-operation shortcuts (#1898)
|
||||
- **drive**: extend permission shortcuts for Miaoda (#2070)
|
||||
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
|
||||
- support source file preview artifacts (#2085)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
|
||||
- **base**: resolve Base URL block types accurately (#2099)
|
||||
- **drive**: use title for default download filename (#2089)
|
||||
- drop stale target version from root upgrade prompt (#2100)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: warn against container-default timezone in time conversion (#2104)
|
||||
- **calendar**: confirm scope before editing recurring events (#2119)
|
||||
- **base**: clarify form and file operation routing (#2110)
|
||||
|
||||
### Misc
|
||||
|
||||
- add protected public domain allowlists (#2111)
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
@@ -1722,6 +1751,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
|
||||
@@ -14,12 +14,16 @@ import (
|
||||
// with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "drive.files.delete"). The envelope does not carry a pre-built retry
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// "drive.files.delete"). The hint is deliberately NOT a pre-built retry
|
||||
// command: argv cannot faithfully reproduce the original invocation (pipeline
|
||||
// producers, stdin bytes, redirections, inline env and the executable's real
|
||||
// path are all gone), POSIX quoting does not survive PowerShell/cmd.exe, and
|
||||
// echoing argv values can copy credentials or free-form payloads (--sql,
|
||||
// --json) into the error envelope and every log that captures it. Per the
|
||||
// lark-shared approval protocol, the caller that obtained the user's consent
|
||||
// appends --yes to its own saved argv array and re-executes.
|
||||
func RequireConfirmation(action string) error {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action)
|
||||
return err.WithHint("add --yes to confirm")
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
|
||||
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
|
||||
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
|
||||
}
|
||||
// The hint is the plain add-yes contract and nothing more: no pre-built
|
||||
// retry command may ride behind it (argv cannot faithfully reproduce the
|
||||
// invocation and may carry sensitive payloads — see RequireConfirmation).
|
||||
if cre.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
t.Errorf("Hint = %q, want exactly 'add --yes to confirm'", cre.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
// No fix_command field leaks into the envelope: the typed protocol stays
|
||||
// action-only.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,11 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
|
||||
|
||||
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
|
||||
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
|
||||
// All paths go through the caller's fileIO provider and its relative-to-cwd
|
||||
// policy — no absolute-path side door: a trust root defined by the process
|
||||
// environment (TMPDIR) is not a security boundary, and reading outside the
|
||||
// provider would break sidecar/custom-FileIO ownership. Out-of-tree content
|
||||
// reaches flags via stdin ("-").
|
||||
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
|
||||
if fileIO == nil {
|
||||
return nil, fmt.Errorf("file input is not available in this context")
|
||||
|
||||
@@ -19,6 +19,9 @@ func SafeOutputPath(path string) (string, error) {
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
|
||||
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
|
||||
// load-bearing invariant. Out-of-tree content reaches flags via stdin ("-").
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
if err != nil {
|
||||
@@ -252,10 +252,11 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
// WHEN: SafeUploadPath validates the absolute temp path
|
||||
// WHEN: SafeInputPath validates the absolute temp path
|
||||
_, err = SafeInputPath(tmpPath)
|
||||
|
||||
// THEN: absolute paths are rejected even in temp dir
|
||||
// THEN: the strict validator rejects it — uploads / drive sync rely on
|
||||
// relative-only; out-of-tree content reaches flags via stdin ("-")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute temp path, got nil")
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.80",
|
||||
"version": "1.0.81",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
71
shortcuts/apps/apps_cache_clear.go
Normal file
71
shortcuts/apps/apps_cache_clear.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheClear clears all cache entries for the app in the given environment.
|
||||
//
|
||||
// POST /apps/{app_id}/cache/clear,body {env}。清空当前应用指定环境下全部缓存,用于无法定位
|
||||
// 具体 key 的快速恢复;影响面大,定 high-risk-write(框架自动注入 --yes 确认)。
|
||||
var AppsCacheClear = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-clear",
|
||||
Description: "Clear all cache entries for the app in the given environment",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appCacheClearPath(appID)).
|
||||
Desc("Clear all cache entries for the app in the given environment").
|
||||
Body(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheClearPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
|
||||
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
|
||||
n := int64(0)
|
||||
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
|
||||
n = int64(f)
|
||||
}
|
||||
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
|
||||
}
|
||||
75
shortcuts/apps/apps_cache_delete.go
Normal file
75
shortcuts/apps/apps_cache_delete.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheDelete deletes a single business cache key (idempotent).
|
||||
//
|
||||
// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建,
|
||||
// 故定 write(非 high-risk-write、不需 --yes)。目标不存在按幂等成功处理(deleted_key_count=0)。
|
||||
var AppsCacheDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-delete",
|
||||
Description: "Delete a single business cache key (idempotent)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-delete --app-id <app_id> --environment dev --key <key>",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "key", Desc: "business cache key", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
DELETE(appCachePath(appID)).
|
||||
Desc("Delete a Miaoda app runtime cache key").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := rctx.Str("key")
|
||||
data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"key": key,
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheDeletePretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。
|
||||
func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) {
|
||||
key := common.GetString(out, "key")
|
||||
if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 {
|
||||
fmt.Fprintf(w, "✓ cache deleted: %s\n", key)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "✓ cache already absent: %s\n", key)
|
||||
}
|
||||
105
shortcuts/apps/apps_cache_get.go
Normal file
105
shortcuts/apps/apps_cache_get.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsCacheGet reads a single business cache key's value + metadata.
|
||||
//
|
||||
// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json
|
||||
// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI
|
||||
// 按 value 字节长度算出(端点不返回);未命中(exists=false)时不带 value,ttl_ms/value_size_bytes 为 null。
|
||||
var AppsCacheGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+cache-get",
|
||||
Description: "Get a business cache key's value and metadata",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
|
||||
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "key", Desc: "business cache key", Required: true},
|
||||
cacheEnvFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appCachePath(appID)).
|
||||
Desc("Get a Miaoda app runtime cache key").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := rctx.Str("key")
|
||||
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := projectCacheGet(data, key, rctx)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderCacheGetPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectCacheGet 组装 cache-get 输出:key 回显、environment 取 resolved env、exists 直读;
|
||||
// 命中时带 ttl_ms + value(原始串)+ value_size_bytes(CLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
|
||||
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
|
||||
exists := cacheBool(data["exists"])
|
||||
out := map[string]interface{}{
|
||||
"key": key,
|
||||
"environment": resolvedEnv(data, rctx),
|
||||
"exists": exists,
|
||||
}
|
||||
if exists {
|
||||
val := common.GetString(data, "value")
|
||||
out["ttl_ms"] = cacheInt(data["ttl_ms"])
|
||||
out["value_size_bytes"] = len([]byte(val))
|
||||
out["value"] = val
|
||||
} else {
|
||||
out["ttl_ms"] = nil
|
||||
out["value_size_bytes"] = nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderCacheGetPretty 打元信息块(key/environment/exists,命中再加 ttl/value_size),命中时末尾展开 value。
|
||||
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
|
||||
exists, _ := out["exists"].(bool)
|
||||
pairs := [][2]string{
|
||||
{"key", common.GetString(out, "key")},
|
||||
{"environment", common.GetString(out, "environment")},
|
||||
{"exists", fmt.Sprintf("%v", exists)},
|
||||
}
|
||||
if exists {
|
||||
pairs = append(pairs,
|
||||
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
|
||||
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
|
||||
)
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
if exists {
|
||||
fmt.Fprintln(w, "value:")
|
||||
printCacheValuePretty(w, common.GetString(out, "value"))
|
||||
}
|
||||
}
|
||||
357
shortcuts/apps/apps_cache_test.go
Normal file
357
shortcuts/apps/apps_cache_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheURL = "/open-apis/spark/v1/apps/app_x/cache"
|
||||
cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear"
|
||||
)
|
||||
|
||||
// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串(value 不反序列化)。
|
||||
const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]`
|
||||
|
||||
// ── cache-get ──
|
||||
|
||||
// TestAppsCacheGet_HitJSON:命中时 json 默认——value 原样透传(不反序列化),
|
||||
// value_size_bytes 由 CLI 按 value 字节长度算出,environment 取服务端 resolved env。
|
||||
func TestAppsCacheGet_HitJSON(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true {
|
||||
t.Fatalf("get hit data=%v", d)
|
||||
}
|
||||
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||
t.Fatalf("value must be raw passthrough string, got %v", d["value"])
|
||||
}
|
||||
if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) {
|
||||
t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr))
|
||||
}
|
||||
// ttl_ms 必须是 JSON number(透传服务端数字,不得变成字符串);JSON 解析后为 float64。
|
||||
if _, ok := d["ttl_ms"].(float64); !ok {
|
||||
t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_HitPretty:pretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
|
||||
func TestAppsCacheGet_HitPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("pretty missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_Miss:未命中——exists=false,无 value,ttl_ms / value_size_bytes 为 null。
|
||||
func TestAppsCacheGet_Miss(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": false,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["exists"] != false {
|
||||
t.Fatalf("miss exists=%v", d["exists"])
|
||||
}
|
||||
if _, ok := d["value"]; ok {
|
||||
t.Fatalf("miss must not carry value: %v", d)
|
||||
}
|
||||
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
|
||||
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_ExistsAsString:服务端把 exists 返成字符串 "true" 时仍按命中处理
|
||||
// (cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
|
||||
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if d["exists"] != true {
|
||||
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
|
||||
}
|
||||
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||
t.Fatalf("命中应带 value, got %v", d["value"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_PrettyNonJSONFallback:pretty 下 value 不是合法 JSON 时降级原样输出
|
||||
// (safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
|
||||
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
|
||||
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_TTLAsStringNormalized:服务端把 ttl_ms 返成字符串 "272000" 时,
|
||||
// 输出的 ttl_ms 必须归一成 JSON number(cacheInt),不得随 wire 形态漂移成字符串。
|
||||
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
f, ok := d["ttl_ms"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||
}
|
||||
if int(f) != 272000 {
|
||||
t.Fatalf("ttl_ms = %v, want 272000", f)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_CountAsStringNormalized:服务端把 deleted_key_count 返成字符串 "1" 时,
|
||||
// 输出必须归一成 JSON number(cacheInt)。
|
||||
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if _, ok := d["deleted_key_count"].(float64); !ok {
|
||||
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_DryRunOmitsEnv:不传 --environment 时 dry-run query 不带 env(服务端自动选),但带 key。
|
||||
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "GET" || a.URL != cacheURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
|
||||
}
|
||||
if a.Params["key"] != "k:1" {
|
||||
t.Fatalf("key must be in query, params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_DryRunWithEnv:显式 --environment dev → query 带 env=dev。
|
||||
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Params["env"] != "dev" {
|
||||
t.Fatalf("env must be dev, params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheGet_RequiresKey:缺 --key → 校验错。
|
||||
func TestAppsCacheGet_RequiresKey(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheGet,
|
||||
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --key error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── cache-delete ──
|
||||
|
||||
// TestAppsCacheDelete_Hit:删中命中的 key → deleted_key_count=1;pretty 打 "✓ cache deleted"。
|
||||
func TestAppsCacheDelete_Hit(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ cache deleted") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_AbsentJSON:目标不存在 → 幂等成功,deleted_key_count=0,pretty 措辞区分。
|
||||
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
d := parseEnvelopeData(t, stdout)
|
||||
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
|
||||
t.Fatalf("absent data=%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_AbsentPretty:不存在 pretty 打 "✓ cache already absent"。
|
||||
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE", URL: cacheURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "already absent") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheDelete_DryRun:DELETE 方法、/cache 路由,query 带 key + env。
|
||||
func TestAppsCacheDelete_DryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "DELETE" || a.URL != cacheURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
|
||||
t.Fatalf("params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// ── cache-clear ──
|
||||
|
||||
// TestAppsCacheClear_Success:清空成功 → deleted_key_count=128;pretty 打 "✓ cache cleared: 128 entries (dev)"。
|
||||
func TestAppsCacheClear_Success(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: cacheClearURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_RequiresConfirmation:high-risk-write 无 --yes → 被确认门拦截。
|
||||
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_DryRunBodyWithEnv:dry-run POST /cache/clear,body 带 env=dev。
|
||||
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if a.Method != "POST" || a.URL != cacheClearURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["env"] != "dev" {
|
||||
t.Fatalf("body must carry env=dev, body=%v", a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsCacheClear_DryRunBodyOmitsEnv:不传 --environment → body 不带 env(服务端自动选)。
|
||||
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsCacheClear,
|
||||
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
a := firstDryRunAPI(t, stdout.String())
|
||||
if _, ok := a.Body["env"]; ok {
|
||||
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项(method/url/params/body)。
|
||||
// 复用本包规范的 dryRunAPIEnvelope(api 现嵌在 data.api 下,见 dryrun_test.go)。
|
||||
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
|
||||
t.Helper()
|
||||
var env dryRunAPIEnvelope
|
||||
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
|
||||
t.Fatalf("bad dry-run json: %v\n%s", err, s)
|
||||
}
|
||||
return env.API[0]
|
||||
}
|
||||
99
shortcuts/apps/cache_common.go
Normal file
99
shortcuts/apps/cache_common.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 应用运行时缓存(Cache)调试命令共享件:路由 + 环境 flag + 渲染。
|
||||
//
|
||||
// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`,按运行环境(env→dbBranch)隔离:
|
||||
// 环境 flag 用 cacheEnvFlag()(只 --environment,不带 db 家族的旧名 --env),env 值经 dbEnv 读、
|
||||
// 经 dbEnvParams 注入——get/delete 放 query,clear 放 body(省略即服务端自动选分支)。
|
||||
|
||||
// appCachePath 返回缓存单 key 读/删 URL:cache(GET 读、DELETE 删,靠方法区分)。
|
||||
func appCachePath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// appCacheClearPath 返回清空指定环境缓存 URL:cache/clear。
|
||||
func appCacheClearPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env,
|
||||
// 故只注册干净的 --environment(不带 db 家族那套隐藏 --env + 拒收逻辑)。
|
||||
// 省略即服务端按应用多环境状态自动选分支(多环境→dev,非多环境→online)。
|
||||
func cacheEnvFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "environment",
|
||||
Enum: []string{"dev", "online"},
|
||||
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
|
||||
}
|
||||
}
|
||||
|
||||
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool,
|
||||
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中(hit→miss 翻转)。
|
||||
func cacheBool(v interface{}) bool {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(x), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cacheInt 把服务端下发的数值字段归一成 int64(无法解析→nil)。本仓惯例:数值可能以字符串下发
|
||||
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
|
||||
// (number ↔ string)。归一后输出类型恒定为数字或 null,消费方无需自己容忍字符串。
|
||||
func cacheInt(raw interface{}) interface{} {
|
||||
if f, ok := numericAsFloat(raw); ok {
|
||||
return int64(f)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvedEnv 取服务端回吐的 resolved env;缺失时兜底成请求侧 --environment(可能为空)。
|
||||
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
|
||||
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
|
||||
if env := common.GetString(data, "env"); env != "" {
|
||||
return env
|
||||
}
|
||||
return dbEnv(rctx)
|
||||
}
|
||||
|
||||
// formatCacheTTL 把剩余 TTL(毫秒)格式化成 4m32s 这样的时长串;非数字返回 "—"。
|
||||
func formatCacheTTL(ms interface{}) string {
|
||||
f, ok := numericAsFloat(ms)
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
return (time.Duration(int64(f)) * time.Millisecond).String()
|
||||
}
|
||||
|
||||
// printCacheValuePretty 把 value 反序列化后缩进展开(pretty 口径);非 JSON 则原样打印。
|
||||
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
|
||||
func printCacheValuePretty(w io.Writer, raw string) {
|
||||
v := safeParseJSON(raw)
|
||||
if s, ok := v.(string); ok {
|
||||
fmt.Fprintln(w, s)
|
||||
return
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(w, raw)
|
||||
return
|
||||
}
|
||||
w.Write(b)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsFileUpload,
|
||||
AppsFileDelete,
|
||||
AppsFileQuotaGet,
|
||||
AppsCacheGet,
|
||||
AppsCacheDelete,
|
||||
AppsCacheClear,
|
||||
AppsGitCredentialInit,
|
||||
AppsGitCredentialList,
|
||||
AppsGitCredentialRemove,
|
||||
|
||||
@@ -20,13 +20,14 @@ import (
|
||||
// - 3 git-credential
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 cache(get/delete/clear)
|
||||
// - 3 plugin(install/uninstall/list)
|
||||
// - 6 automation(list/get/create/update/enable/disable)
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 79。
|
||||
func TestAppsShortcuts_Returns79(t *testing.T) {
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 82。
|
||||
func TestAppsShortcuts_Returns82(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 79 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
|
||||
if len(got) != 82 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -27,19 +28,23 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
|
||||
"Each new question creates a field in the form's table; question IDs are field IDs.",
|
||||
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
api := common.NewDryRunAPI().
|
||||
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
Set("form_id", runtime.Str("form-id")).
|
||||
Body(map[string]interface{}{"questions": questions})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
@@ -47,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
formId := runtime.Str("form-id")
|
||||
questionsJSON := runtime.Str("questions")
|
||||
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
questions, err := parseFormQuestionsCreate(questionsJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
@@ -78,3 +83,31 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
|
||||
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
if questions == nil {
|
||||
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
|
||||
}
|
||||
if len(questions) > 10 {
|
||||
return nil, baseValidationErrorf("--questions must contain at most 10 items")
|
||||
}
|
||||
for i, question := range questions {
|
||||
item, ok := question.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
|
||||
}
|
||||
title, ok := item["title"].(string)
|
||||
if !ok || strings.TrimSpace(title) == "" {
|
||||
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
|
||||
}
|
||||
questionType, ok := item["type"].(string)
|
||||
if !ok || strings.TrimSpace(questionType) == "" {
|
||||
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
|
||||
}
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
|
||||
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
|
||||
for _, want := range []string{
|
||||
"+form-questions-list",
|
||||
"verified empty form can create directly",
|
||||
"question IDs are field IDs",
|
||||
"explicitly requests a separate same-title question",
|
||||
"+form-questions-update",
|
||||
} {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ type RuntimeContext struct {
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
inputResolved map[string]bool // flags whose value was replaced by @file / stdin content in resolveInputFlags; see InputResolvedFromSource
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -1016,6 +1017,25 @@ func stripUTF8BOM(s string) string {
|
||||
return strings.TrimPrefix(s, "\uFEFF")
|
||||
}
|
||||
|
||||
// InputResolvedFromSource reports whether the named flag's value was loaded
|
||||
// from an external source (@file or stdin `-`) by resolveInputFlags, as
|
||||
// opposed to typed inline on the command line. Domain guards that apply
|
||||
// shape heuristics to inline values ("this looks like a file path — did you
|
||||
// forget the @?") must skip resolved values: their content was already read
|
||||
// from the right place and may legitimately look like anything, including a
|
||||
// path. Without this bit such a guard re-rejects correct @file / stdin
|
||||
// invocations, because by the time Validate runs both arrive as plain text.
|
||||
func (ctx *RuntimeContext) InputResolvedFromSource(name string) bool {
|
||||
return ctx.inputResolved[name]
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) markInputResolved(name string) {
|
||||
if ctx.inputResolved == nil {
|
||||
ctx.inputResolved = map[string]bool{}
|
||||
}
|
||||
ctx.inputResolved[name] = true
|
||||
}
|
||||
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
@@ -1055,6 +1075,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
||||
// cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
rctx.markInputResolved(fl.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1091,6 +1112,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
rctx.markInputResolved(fl.Name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func TestResolveInputFlags_DirectValue(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != "hello world" {
|
||||
t.Errorf("expected %q, got %q", "hello world", got)
|
||||
}
|
||||
if rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("inline value must not be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
@@ -55,6 +58,9 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != "content from stdin" {
|
||||
t.Errorf("expected %q, got %q", "content from stdin", got)
|
||||
}
|
||||
if !rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("stdin value should be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_File(t *testing.T) {
|
||||
@@ -75,6 +81,27 @@ func TestResolveInputFlags_File(t *testing.T) {
|
||||
if got := rctx.Str("markdown"); got != content {
|
||||
t.Errorf("expected %q, got %q", content, got)
|
||||
}
|
||||
if !rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("@file value should be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveInputFlags_EscapedAtStaysInline pins that the @@ escape is
|
||||
// inline content (a literal leading @), not an external source — heuristic
|
||||
// guards keyed on InputResolvedFromSource must still see it.
|
||||
func TestResolveInputFlags_EscapedAtStaysInline(t *testing.T) {
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@@handle"}, "")
|
||||
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("markdown"); got != "@handle" {
|
||||
t.Errorf("expected %q, got %q", "@handle", got)
|
||||
}
|
||||
if rctx.InputResolvedFromSource("markdown") {
|
||||
t.Error("escaped @@ value must not be marked as resolved from a source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_EmptyFile(t *testing.T) {
|
||||
|
||||
@@ -39,6 +39,13 @@ func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, i
|
||||
return rctx
|
||||
}
|
||||
|
||||
// TestMarkInputResolved marks a flag as resolved from @file / stdin, so
|
||||
// domain tests can exercise guards that branch on InputResolvedFromSource
|
||||
// without wiring the full resolveInputFlags path.
|
||||
func TestMarkInputResolved(rctx *RuntimeContext, name string) {
|
||||
rctx.markInputResolved(name)
|
||||
}
|
||||
|
||||
// TestNewRuntimeContextForAPI creates a RuntimeContext ready for HTTP tests:
|
||||
// sets Cmd, Config, Factory, context, and the requested identity so callers
|
||||
// can invoke DoAPI / CallAPI directly without wiring through a cobra parent
|
||||
|
||||
@@ -202,7 +202,7 @@ var DriveDownload = common.Shortcut{
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
if err != nil {
|
||||
return wrapDriveNetworkErr(err, "download failed: %s", err)
|
||||
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ package drive
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -21,6 +23,30 @@ func wrapDriveNetworkErr(err error, format string, args ...any) error {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// withDriveDownloadForbiddenPreviewHint keeps the HTTP 403 network error from
|
||||
// +download intact while giving callers a preview-based path to view content.
|
||||
func withDriveDownloadForbiddenPreviewHint(err error, _ string) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusForbidden {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(problem.Hint, "drive +preview") {
|
||||
return err
|
||||
}
|
||||
hint := driveDownloadForbiddenPreviewHint()
|
||||
if strings.TrimSpace(problem.Hint) == "" {
|
||||
problem.Hint = hint
|
||||
return err
|
||||
}
|
||||
problem.Hint = strings.TrimSpace(problem.Hint) + " " + hint
|
||||
return err
|
||||
}
|
||||
|
||||
func driveDownloadForbiddenPreviewHint() string {
|
||||
const tokenArg = "<FILE_TOKEN>"
|
||||
return fmt.Sprintf("Direct Drive download returned HTTP 403. To view file content through preview artifacts, try `lark-cli drive +preview --file-token %s --type source_file --output <path>`; for PDF/text/image preview choices, run `lark-cli drive +preview --file-token %s --list-only`.", tokenArg, tokenArg)
|
||||
}
|
||||
|
||||
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
|
||||
// to a typed validation error:
|
||||
// - Path validation failures → "unsafe file path: ..."
|
||||
|
||||
@@ -1580,6 +1580,84 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadHTTP403SuggestsPreview(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_403/download",
|
||||
Status: http.StatusForbidden,
|
||||
RawBody: []byte("permission denied"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_403",
|
||||
"--output", "blocked.md",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP 403 error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category=%q, want network", problem.Category)
|
||||
}
|
||||
if problem.Code != http.StatusForbidden {
|
||||
t.Fatalf("code=%d, want %d", problem.Code, http.StatusForbidden)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "drive +preview") {
|
||||
t.Fatalf("hint=%q, want preview guidance", problem.Hint)
|
||||
}
|
||||
if strings.Contains(problem.Hint, "file_403") {
|
||||
t.Fatalf("hint=%q, want placeholder file token", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--file-token <FILE_TOKEN>") {
|
||||
t.Fatalf("hint=%q, want file token placeholder", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output <path>") {
|
||||
t.Fatalf("hint=%q, want source_file output command", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadHTTP404DoesNotSuggestPreview(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/file_missing/download",
|
||||
Status: http.StatusNotFound,
|
||||
RawBody: []byte("not found"),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DriveDownload, []string{
|
||||
"+download",
|
||||
"--file-token", "file_missing",
|
||||
"--output", "missing.md",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP 404 error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Code != http.StatusNotFound {
|
||||
t.Fatalf("code=%d, want %d", problem.Code, http.StatusNotFound)
|
||||
}
|
||||
if strings.Contains(problem.Hint, "drive +preview") {
|
||||
t.Fatalf("hint=%q, want no preview guidance for non-403", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
|
||||
header := http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="////"`},
|
||||
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
var DrivePreview = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+preview",
|
||||
Description: "List or download available preview artifacts for a Drive file",
|
||||
Description: "View or download Drive file content, or list and fetch available preview artifacts",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "Drive file token", Required: true},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
|
||||
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source_file"},
|
||||
{Name: "version", Desc: "optional file version"},
|
||||
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
|
||||
{Name: "output", Desc: "local output path for downloaded preview"},
|
||||
@@ -40,6 +40,25 @@ var DrivePreview = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
fileToken := runtime.Str("file-token")
|
||||
version := strings.TrimSpace(runtime.Str("version"))
|
||||
requestedType := strings.TrimSpace(runtime.Str("type"))
|
||||
if requestedType == "source_file" {
|
||||
downloadParams := map[string]interface{}{
|
||||
"preview_type": drivePreviewTypeSourceFile,
|
||||
}
|
||||
if version != "" {
|
||||
downloadParams["version"] = version
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("Download the source file artifact").
|
||||
Params(downloadParams).
|
||||
Set("file_token", fileToken).
|
||||
Set("mode", "download").
|
||||
Set("requested_type", requestedType).
|
||||
Set("selected_type", "source_file").
|
||||
Set("selected_type_code", drivePreviewTypeSourceFile).
|
||||
Set("output", runtime.Str("output"))
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if version != "" {
|
||||
body["version"] = version
|
||||
@@ -67,7 +86,7 @@ var DrivePreview = common.Shortcut{
|
||||
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
|
||||
Params(downloadParams).
|
||||
Set("mode", "download").
|
||||
Set("requested_type", runtime.Str("type")).
|
||||
Set("requested_type", requestedType).
|
||||
Set("output", runtime.Str("output"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -82,9 +101,25 @@ var DrivePreview = common.Shortcut{
|
||||
body["version"] = version
|
||||
}
|
||||
|
||||
if requestedType == "source_file" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading source file artifact: %s\n", common.MaskToken(fileToken))
|
||||
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, drivePreviewTypeSourceFile, version, outputPath, ifExists, drivePreviewFallbackExt("source_file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["mode"] = "download"
|
||||
result["file_token"] = fileToken
|
||||
result["selected_type"] = "source_file"
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
|
||||
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
|
||||
if err != nil {
|
||||
if runtime.Bool("list-only") {
|
||||
return withDrivePreviewSourceFileHint(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("list-only") {
|
||||
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
drivePreviewIfExistsError = "error"
|
||||
drivePreviewIfExistsOverwrite = "overwrite"
|
||||
drivePreviewIfExistsRename = "rename"
|
||||
drivePreviewTypeSourceFile = "16"
|
||||
drivePreviewSourceFileHint = "Preview candidates are unavailable for this file. To fetch the source file artifact, rerun with --type source_file --output <path>."
|
||||
)
|
||||
|
||||
type drivePreviewCandidate struct {
|
||||
@@ -88,7 +90,9 @@ var drivePreviewMimeToExt = map[string]string{
|
||||
"image/webp": ".webp",
|
||||
"text/csv": ".csv",
|
||||
"text/html": ".html",
|
||||
"text/markdown": ".md",
|
||||
"text/plain": ".txt",
|
||||
"text/x-markdown": ".md",
|
||||
"text/xml": ".xml",
|
||||
"video/mp4": ".mp4",
|
||||
"application/octet-stream": "",
|
||||
@@ -464,7 +468,7 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
|
||||
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists, fileToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -492,8 +496,8 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
|
||||
|
||||
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
|
||||
// inference and the selected collision policy.
|
||||
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists, fallbackName string) (string, *driveExtensionResolution, error) {
|
||||
finalPath, resolution := resolveDrivePreviewOutputPathName(runtime, outputPath, header, fallbackExt, fallbackName)
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
|
||||
}
|
||||
@@ -522,6 +526,32 @@ func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath st
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDrivePreviewOutputPathName(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
|
||||
if drivePreviewOutputIsDirectory(runtime, outputPath) {
|
||||
fileName, resolution := drivePreviewDefaultFileName(header, fallbackExt, fallbackName)
|
||||
return filepath.Join(outputPath, fileName), resolution
|
||||
}
|
||||
return autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
|
||||
}
|
||||
|
||||
func drivePreviewOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
|
||||
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
|
||||
return true
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(outputPath)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func drivePreviewDefaultFileName(header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
|
||||
name := driveDownloadNormalizeFileName(larkcore.FileNameByHeader(header))
|
||||
if name == "" {
|
||||
name = driveDownloadNormalizeFileName(fallbackName)
|
||||
}
|
||||
name = sanitizeExportFileName(name, "preview")
|
||||
name, resolution := autoAppendDrivePreviewExtension(name, header, fallbackExt)
|
||||
return name, resolution
|
||||
}
|
||||
|
||||
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
|
||||
// target output path.
|
||||
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
|
||||
@@ -556,6 +586,15 @@ func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fall
|
||||
if filepath.Ext(outputPath) == "." {
|
||||
normalizedPath = strings.TrimSuffix(outputPath, ".")
|
||||
}
|
||||
if fallbackExt == "" {
|
||||
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
return normalizedPath, nil
|
||||
}
|
||||
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
|
||||
return normalizedPath + resolution.Ext, resolution
|
||||
}
|
||||
@@ -804,6 +843,36 @@ func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePrevie
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
|
||||
}
|
||||
|
||||
// withDrivePreviewSourceFileHint adds source_file guidance to preview candidate
|
||||
// API failures without changing their classification or server diagnostics.
|
||||
func withDrivePreviewSourceFileHint(err error) error {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryAPI {
|
||||
return err
|
||||
}
|
||||
if problem.Retryable || problem.Subtype == errs.SubtypeRateLimit {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(problem.Hint, "--type source_file") {
|
||||
return err
|
||||
}
|
||||
if !isDrivePreviewCandidatesUnavailableProblem(problem) {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(problem.Hint) == "" {
|
||||
problem.Hint = drivePreviewSourceFileHint
|
||||
return err
|
||||
}
|
||||
problem.Hint = strings.TrimSpace(problem.Hint) + " " + drivePreviewSourceFileHint
|
||||
return err
|
||||
}
|
||||
|
||||
func isDrivePreviewCandidatesUnavailableProblem(problem *errs.Problem) bool {
|
||||
return problem != nil &&
|
||||
problem.Code == 1 &&
|
||||
strings.Contains(problem.Message, "mGetFilePreviewCore failed")
|
||||
}
|
||||
|
||||
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
|
||||
// spec.
|
||||
func wrapDriveCoverUnavailable(requested string) error {
|
||||
|
||||
@@ -147,6 +147,63 @@ func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult verifies
|
||||
// source_file downloads the source file artifact without first fetching preview
|
||||
// candidates.
|
||||
func TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/file_source/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
Body: []byte("# markdown\n"),
|
||||
Headers: http.Header{
|
||||
"Content-Disposition": []string{`attachment; filename="README.md"`},
|
||||
"Content-Type": []string{"text/plain; charset=utf-8"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_source",
|
||||
"--type", "source_file",
|
||||
"--output", "artifacts/",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if _, ok := data["requested_type"]; ok {
|
||||
t.Fatalf("requested_type should be omitted from execute output: %#v", data)
|
||||
}
|
||||
if got := data["selected_type"]; got != "source_file" {
|
||||
t.Fatalf("selected_type=%v, want source_file", got)
|
||||
}
|
||||
if _, ok := data["selected_type_code"]; ok {
|
||||
t.Fatalf("selected_type_code should be omitted from execute output: %#v", data)
|
||||
}
|
||||
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks() error: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(resolvedTmpDir, "artifacts", "README.md")
|
||||
if got := data["output_path"]; got != wantPath {
|
||||
t.Fatalf("output_path=%v, want %s", got, wantPath)
|
||||
}
|
||||
gotBody, err := os.ReadFile(wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error: %v", wantPath, err)
|
||||
}
|
||||
if string(gotBody) != "# markdown\n" {
|
||||
t.Fatalf("saved body=%q, want markdown source", string(gotBody))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
|
||||
// return an actionable validation error.
|
||||
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
|
||||
@@ -434,6 +491,72 @@ func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunSourceFileDocumentsDirectDownload verifies source_file
|
||||
// dry-run documents the direct source artifact download path.
|
||||
func TestDrivePreviewDryRunSourceFileDocumentsDirectDownload(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_source",
|
||||
"type": "source_file",
|
||||
"version": "7",
|
||||
"output": "source",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
if got := data["mode"]; got != "download" {
|
||||
t.Fatalf("mode=%v, want download", got)
|
||||
}
|
||||
if got := data["requested_type"]; got != "source_file" {
|
||||
t.Fatalf("requested_type=%v, want source_file", got)
|
||||
}
|
||||
if got := data["selected_type"]; got != "source_file" {
|
||||
t.Fatalf("selected_type=%v, want source_file", got)
|
||||
}
|
||||
if got := data["selected_type_code"]; got != drivePreviewTypeSourceFile {
|
||||
t.Fatalf("selected_type_code=%v, want %s", got, drivePreviewTypeSourceFile)
|
||||
}
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 1 {
|
||||
t.Fatalf("len(api)=%d, want 1", len(api))
|
||||
}
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if got := call["method"]; got != "GET" {
|
||||
t.Fatalf("method=%v, want GET", got)
|
||||
}
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_download" {
|
||||
t.Fatalf("url=%v, want preview_download", got)
|
||||
}
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["preview_type"]; got != drivePreviewTypeSourceFile {
|
||||
t.Fatalf("params.preview_type=%v, want %s", got, drivePreviewTypeSourceFile)
|
||||
}
|
||||
if got := params["version"]; got != "7" {
|
||||
t.Fatalf("params.version=%v, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates verifies only the
|
||||
// explicit source_file request bypasses preview_result.
|
||||
func TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
|
||||
"file-token": "file_source",
|
||||
"type": "source",
|
||||
"output": "source",
|
||||
}, nil)
|
||||
|
||||
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
|
||||
api, _ := data["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("len(api)=%d, want 2", len(api))
|
||||
}
|
||||
call, _ := api[0].(map[string]interface{})
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_result" {
|
||||
t.Fatalf("url=%v, want preview_result", got)
|
||||
}
|
||||
if _, ok := data["selected_type_code"]; ok {
|
||||
t.Fatalf("selected_type_code should be omitted for non-source_file dry-run: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
|
||||
// omits the request body when no version is supplied.
|
||||
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
|
||||
@@ -612,6 +735,135 @@ func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewListOnlyErrorAddsSourceFileHint verifies preview_result API
|
||||
// failures keep server diagnostics while guiding callers to source_file.
|
||||
func TestDrivePreviewListOnlyErrorAddsSourceFileHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/file_markdown/preview_result",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1,
|
||||
"msg": "fail:mGetFilePreviewCore failed",
|
||||
"log_id": "log-preview-result",
|
||||
"error": map[string]interface{}{
|
||||
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/preview-result",
|
||||
"details": []interface{}{
|
||||
map[string]interface{}{"value": "server preview_result detail"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePreview, []string{
|
||||
"+preview",
|
||||
"--file-token", "file_markdown",
|
||||
"--list-only",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected preview_result error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("category=%q, want api", problem.Category)
|
||||
}
|
||||
if problem.Code != 1 {
|
||||
t.Fatalf("code=%d, want 1", problem.Code)
|
||||
}
|
||||
if problem.LogID != "log-preview-result" {
|
||||
t.Fatalf("log_id=%q, want log-preview-result", problem.LogID)
|
||||
}
|
||||
if problem.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/preview-result" {
|
||||
t.Fatalf("troubleshooter=%q, want passthrough", problem.Troubleshooter)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "server preview_result detail") {
|
||||
t.Fatalf("hint=%q, want server detail preserved", problem.Hint)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output") {
|
||||
t.Fatalf("hint=%q, want source_file output guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewListOnlyRateLimitKeepsOriginalHint verifies retryable API
|
||||
// errors are not reframed as source_file recovery.
|
||||
func TestDrivePreviewListOnlyRateLimitKeepsOriginalHint(t *testing.T) {
|
||||
err := withDrivePreviewSourceFileHint(errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithCode(99991400).WithRetryable())
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Hint != "" {
|
||||
t.Fatalf("hint=%q, want empty hint for rate limit", problem.Hint)
|
||||
}
|
||||
if !problem.Retryable {
|
||||
t.Fatal("retryable=false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewSourceFileHintGuards verifies source_file recovery guidance
|
||||
// only rewrites eligible API errors and preserves existing source_file hints.
|
||||
func TestDrivePreviewSourceFileHintGuards(t *testing.T) {
|
||||
plainErr := errors.New("plain failure")
|
||||
if got := withDrivePreviewSourceFileHint(plainErr); got != plainErr {
|
||||
t.Fatalf("non-API error changed: got %T %v, want original", got, got)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
err *errs.APIError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "already has source file hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed").WithHint("rerun with --type source_file --output <path>"),
|
||||
want: "rerun with --type source_file --output <path>",
|
||||
},
|
||||
{
|
||||
name: "candidate core failure empty hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1),
|
||||
want: drivePreviewSourceFileHint,
|
||||
},
|
||||
{
|
||||
name: "candidate core failure whitespace hint",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1).WithHint(" \n\t "),
|
||||
want: drivePreviewSourceFileHint,
|
||||
},
|
||||
{
|
||||
name: "generic server error",
|
||||
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed"),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
err: errs.NewAPIError(errs.SubtypeNotFound, "file not found").WithCode(1061044),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "invalid parameters",
|
||||
err: errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid file token").WithCode(1063007),
|
||||
want: "",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotErr := withDrivePreviewSourceFileHint(tt.err)
|
||||
if gotErr != tt.err {
|
||||
t.Fatalf("API error pointer changed: got %T, want original", gotErr)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(gotErr)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", gotErr, gotErr)
|
||||
}
|
||||
if problem.Hint != tt.want {
|
||||
t.Fatalf("hint=%q, want %q", problem.Hint, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
|
||||
// validation error with available alternatives.
|
||||
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
|
||||
@@ -721,6 +973,21 @@ func TestDrivePreviewCommonHelpers(t *testing.T) {
|
||||
if path != "cover.pdf" || fallback != nil {
|
||||
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
|
||||
}
|
||||
|
||||
header = http.Header{}
|
||||
header.Set("Content-Type", "text/plain")
|
||||
header.Set("Content-Disposition", `attachment; filename="README.md"`)
|
||||
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
|
||||
if path != "source.md" || fallback == nil || fallback.Source != "Content-Disposition" {
|
||||
t.Fatalf("source_file append = (%q, %+v), want source.md from Content-Disposition", path, fallback)
|
||||
}
|
||||
|
||||
header = http.Header{}
|
||||
header.Set("Content-Type", "text/plain")
|
||||
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
|
||||
if path != "source.txt" || fallback == nil || fallback.Source != "Content-Type" {
|
||||
t.Fatalf("source_file content-type append = (%q, %+v), want source.txt from Content-Type", path, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
|
||||
@@ -751,7 +1018,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
header := http.Header{}
|
||||
header.Set("Content-Type", "application/pdf")
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
|
||||
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename, "file_preview")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
|
||||
}
|
||||
@@ -759,7 +1026,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
|
||||
}
|
||||
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep", "file_preview")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid if-exists error, got nil")
|
||||
}
|
||||
@@ -771,6 +1038,20 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
|
||||
}
|
||||
|
||||
if err := os.Mkdir("artifacts", 0755); err != nil {
|
||||
t.Fatalf("Mkdir() error: %v", err)
|
||||
}
|
||||
sourceHeader := http.Header{}
|
||||
sourceHeader.Set("Content-Type", "text/plain")
|
||||
sourceHeader.Set("Content-Disposition", `attachment; filename="README.md"`)
|
||||
dirOutput, _, err := resolveDrivePreviewOutputPath(runtime, "artifacts", sourceHeader, "", drivePreviewIfExistsError, "file_source")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(directory) error: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(dirOutput, filepath.Join("artifacts", "README.md")) {
|
||||
t.Fatalf("dirOutput=%q, want artifacts/README.md suffix", dirOutput)
|
||||
}
|
||||
|
||||
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
|
||||
@@ -779,7 +1060,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
|
||||
}
|
||||
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
|
||||
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite, "file_preview")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
|
||||
}
|
||||
@@ -791,7 +1072,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
|
||||
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
|
||||
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
|
||||
runtimeWithStatErr.Factory = f
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
|
||||
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError, "file_preview")
|
||||
if err == nil {
|
||||
t.Fatal("expected stat permission error, got nil")
|
||||
}
|
||||
@@ -876,7 +1157,6 @@ func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
|
||||
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
|
||||
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
|
||||
}
|
||||
|
||||
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
|
||||
if len(aliases) == 0 || aliases[0] != "image" {
|
||||
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
|
||||
markdownUploadFinishAction = "finalize markdown multipart upload failed"
|
||||
markdownFetchNameAction = "fetch existing markdown file name failed"
|
||||
markdownSourceFilePreviewType = "16"
|
||||
)
|
||||
|
||||
var markdownUploadRetryBackoffs = []time.Duration{
|
||||
@@ -192,9 +193,14 @@ func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markd
|
||||
}
|
||||
|
||||
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
|
||||
query, err := markdownSourceFilePreviewQuery("", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
|
||||
QueryParams: query,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, wrapMarkdownDownloadError(err)
|
||||
@@ -230,15 +236,15 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (*http.Response, string, error) {
|
||||
query, err := markdownSourceFilePreviewQuery(version, versionParam)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
req.QueryParams = larkcore.QueryParams{
|
||||
"version": []string{strings.TrimSpace(version)},
|
||||
}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
|
||||
QueryParams: query,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, req)
|
||||
@@ -248,6 +254,58 @@ func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeCon
|
||||
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewQuery(version, versionParam string) (larkcore.QueryParams, error) {
|
||||
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := larkcore.QueryParams{
|
||||
"preview_type": []string{markdownSourceFilePreviewType},
|
||||
}
|
||||
if version != "" {
|
||||
query["version"] = []string{version}
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewDryRunParams(version, versionParam string) (map[string]interface{}, error) {
|
||||
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"preview_type": markdownSourceFilePreviewType,
|
||||
}
|
||||
if version != "" {
|
||||
params["version"] = version
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func markdownSourceFilePreviewDryRunParamsForValidatedVersion(version, versionParam string) map[string]interface{} {
|
||||
params, err := markdownSourceFilePreviewDryRunParams(version, versionParam)
|
||||
if err != nil {
|
||||
// Shortcut validation runs before DryRun. If a caller bypasses that
|
||||
// contract, preserve the supplied value instead of silently dropping it.
|
||||
params = map[string]interface{}{
|
||||
"preview_type": markdownSourceFilePreviewType,
|
||||
"version": version,
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func validateMarkdownSourceFilePreviewVersion(version, flagName string) error {
|
||||
if version == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
return nil
|
||||
}
|
||||
if flagName == "" {
|
||||
flagName = "--version"
|
||||
}
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
}
|
||||
|
||||
func markdownDryRunFileField(spec markdownUploadSpec) string {
|
||||
if spec.FilePath != "" {
|
||||
return "@" + spec.FilePath
|
||||
|
||||
@@ -112,9 +112,8 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
|
||||
}
|
||||
|
||||
func validateMarkdownDiffVersionValue(value, flagName string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
|
||||
if err := validateMarkdownSourceFilePreviewVersion(value, flagName); err != nil {
|
||||
return err
|
||||
}
|
||||
if !markdownDiffVersionRe.MatchString(value) {
|
||||
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
|
||||
@@ -134,31 +133,33 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
switch markdownDiffMode(spec) {
|
||||
case markdownDiffModeRemoteVsLocal:
|
||||
if spec.FromVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the specified remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the specified remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the latest remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
|
||||
}
|
||||
dry.Set("local_file", spec.FilePath)
|
||||
dry.Set("mode", markdownDiffModeRemoteVsLocal)
|
||||
default:
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the base remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the base remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.FromVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
|
||||
if spec.ToVersion != "" {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the target remote Markdown version").
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[2] Download the target remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(map[string]interface{}{"version": spec.ToVersion})
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.ToVersion, "--to-version"))
|
||||
} else {
|
||||
dry.GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[2] Download the latest remote Markdown version").
|
||||
Set("file_token", spec.FileToken)
|
||||
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[2] Download the latest remote Markdown source file preview artifact").
|
||||
Set("file_token", spec.FileToken).
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
|
||||
}
|
||||
dry.Set("mode", markdownDiffModeRemoteVsRemote)
|
||||
}
|
||||
@@ -166,8 +167,8 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
|
||||
return dry
|
||||
}
|
||||
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
|
||||
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (string, string, error) {
|
||||
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version, versionParam)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -446,8 +447,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
Format: runtime.Format,
|
||||
@@ -456,8 +457,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return markdownDiffDryRun(markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
})
|
||||
@@ -465,8 +466,8 @@ var MarkdownDiff = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := markdownDiffSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
|
||||
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
|
||||
FromVersion: runtime.Str("from-version"),
|
||||
ToVersion: runtime.Str("to-version"),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
ContextLines: runtime.Int("context-lines"),
|
||||
}
|
||||
@@ -487,7 +488,7 @@ var MarkdownDiff = common.Shortcut{
|
||||
} else {
|
||||
fromLabel += "@latest"
|
||||
}
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -499,17 +500,17 @@ var MarkdownDiff = common.Shortcut{
|
||||
}
|
||||
default:
|
||||
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
|
||||
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if spec.ToVersion != "" {
|
||||
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion, "--to-version")
|
||||
} else {
|
||||
toLabel = "b/" + spec.FileToken + "@latest"
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
|
||||
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "", "")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -48,6 +48,73 @@ func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownDiffRejectsBlankVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "from version",
|
||||
args: []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", " \t",
|
||||
"--file", "./local.md",
|
||||
},
|
||||
wantParam: "--from-version",
|
||||
},
|
||||
{
|
||||
name: "to version",
|
||||
args: []string{
|
||||
"+diff",
|
||||
"--file-token", "box_md_diff",
|
||||
"--from-version", "7633658129540910621",
|
||||
"--to-version", " ",
|
||||
},
|
||||
wantParam: "--to-version",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownDiff, tt.args, f, stdout)
|
||||
requireMarkdownValidationParam(t, err, tt.wantParam)
|
||||
if !strings.Contains(err.Error(), "cannot be empty") {
|
||||
t.Fatalf("expected empty version validation error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownSourceFilePreviewParamsValidateAndPreserveVersion(t *testing.T) {
|
||||
version := " 7633658129540910621 "
|
||||
|
||||
query, err := markdownSourceFilePreviewQuery(version, "--from-version")
|
||||
if err != nil {
|
||||
t.Fatalf("markdownSourceFilePreviewQuery() error: %v", err)
|
||||
}
|
||||
if got := query["version"]; len(got) != 1 || got[0] != version {
|
||||
t.Fatalf("query version = %#v, want original %q", got, version)
|
||||
}
|
||||
|
||||
params, err := markdownSourceFilePreviewDryRunParams(version, "--from-version")
|
||||
if err != nil {
|
||||
t.Fatalf("markdownSourceFilePreviewDryRunParams() error: %v", err)
|
||||
}
|
||||
if got := params["version"]; got != version {
|
||||
t.Fatalf("dry-run version = %#v, want original %q", got, version)
|
||||
}
|
||||
|
||||
_, err = markdownSourceFilePreviewQuery(" \n", "--from-version")
|
||||
requireMarkdownValidationParam(t, err, "--from-version")
|
||||
_, err = markdownSourceFilePreviewDryRunParams(" \t", "--to-version")
|
||||
requireMarkdownValidationParam(t, err, "--to-version")
|
||||
}
|
||||
|
||||
func TestMarkdownDiffMissingVersionAndFileNamesCandidateParams(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
@@ -79,7 +146,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
|
||||
Headers: http.Header{
|
||||
@@ -88,7 +155,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
|
||||
Headers: http.Header{
|
||||
@@ -151,7 +218,7 @@ func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n\nhello old\n"),
|
||||
Headers: http.Header{
|
||||
@@ -191,7 +258,7 @@ func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
|
||||
})
|
||||
@@ -218,7 +285,7 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
@@ -337,7 +404,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
|
||||
Headers: http.Header{
|
||||
@@ -346,7 +413,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
|
||||
Status: 200,
|
||||
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
|
||||
Headers: http.Header{
|
||||
@@ -398,13 +465,13 @@ func TestMarkdownDiffNoChangesPretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_diff/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# Title\n"),
|
||||
})
|
||||
@@ -445,8 +512,11 @@ func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
|
||||
t.Fatalf("dry-run missing download call: %s", stdout.String())
|
||||
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/medias/box_md_diff/preview_download") {
|
||||
t.Fatalf("dry-run missing source preview download call: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"preview_type": "16"`) {
|
||||
t.Fatalf("dry-run missing source_file preview_type: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
|
||||
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())
|
||||
|
||||
@@ -5,14 +5,10 @@ package markdown
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -47,8 +43,9 @@ var MarkdownFetch = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI().
|
||||
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("download markdown source file preview artifact bytes; when --output is omitted the CLI returns content as UTF-8 text").
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
|
||||
Set("file_token", runtime.Str("file-token"))
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
dry.Set("output", outputPath)
|
||||
@@ -61,12 +58,9 @@ var MarkdownFetch = common.Shortcut{
|
||||
fileToken := strings.TrimSpace(runtime.Str("file-token"))
|
||||
outputPath := strings.TrimSpace(runtime.Str("output"))
|
||||
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
})
|
||||
resp, err := openMarkdownDownload(ctx, runtime, fileToken)
|
||||
if err != nil {
|
||||
return wrapMarkdownDownloadError(err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ var MarkdownPatch = common.Shortcut{
|
||||
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
|
||||
GET("/open-apis/drive/v1/files/:file_token/download").
|
||||
Desc("[1] Download the current Markdown content").
|
||||
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
|
||||
Desc("[1] Download the current Markdown source file preview artifact").
|
||||
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
|
||||
Set("file_token", spec.FileToken).
|
||||
POST("/open-apis/drive/v1/metas/batch_query").
|
||||
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").
|
||||
|
||||
@@ -85,9 +85,12 @@ func TestMarkdownPatchDryRunLiteral(t *testing.T) {
|
||||
if got := len(dry.API); got != 6 {
|
||||
t.Fatalf("api steps = %d, want 6", got)
|
||||
}
|
||||
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
|
||||
if got := dry.API[0].URL; got != "/open-apis/drive/v1/medias/box_md_patch/preview_download" {
|
||||
t.Fatalf("download url = %q", got)
|
||||
}
|
||||
if got := dry.API[0].Params["preview_type"]; got != markdownSourceFilePreviewType {
|
||||
t.Fatalf("download preview_type = %#v", got)
|
||||
}
|
||||
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("metas url = %q", got)
|
||||
}
|
||||
@@ -120,7 +123,7 @@ func TestMarkdownPatchDryRunRegex(t *testing.T) {
|
||||
if got := dry.Mode; got != markdownPatchModeRegex {
|
||||
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
|
||||
}
|
||||
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
|
||||
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown source file preview artifact") {
|
||||
t.Fatalf("download desc = %q", got)
|
||||
}
|
||||
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
|
||||
@@ -144,7 +147,7 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
})
|
||||
@@ -187,7 +190,7 @@ func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
})
|
||||
@@ -224,7 +227,7 @@ func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# TODO\nTODO\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -299,7 +302,7 @@ func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# TODO\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -360,7 +363,7 @@ func TestMarkdownPatchRegexOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("Version: 12\nVersion: 34\n"),
|
||||
})
|
||||
@@ -429,7 +432,7 @@ func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("hello world\n"),
|
||||
})
|
||||
@@ -478,7 +481,7 @@ func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_patch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("hello\n"),
|
||||
})
|
||||
@@ -509,9 +512,10 @@ func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]inter
|
||||
type markdownPatchDryRunOutput struct {
|
||||
Mode string `json:"mode"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1984,7 +1984,7 @@ func TestMarkdownFetchReturnsContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2050,7 +2050,7 @@ func TestMarkdownFetchPrettyReturnsContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2078,7 +2078,7 @@ func TestMarkdownFetchSavesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2122,7 +2122,7 @@ func TestMarkdownFetchRejectsExistingFileWithoutOverwrite(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2151,7 +2151,7 @@ func TestMarkdownFetchOverwritesExistingFileWhenRequested(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2189,7 +2189,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testi
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2226,7 +2226,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputUsesDirectorySyntax(t *testi
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2260,7 +2260,7 @@ func TestMarkdownFetchPrettySavesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
@@ -2295,7 +2295,7 @@ func TestMarkdownFetchSaveFailure(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
|
||||
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
|
||||
Status: 200,
|
||||
RawBody: []byte("# hello\n"),
|
||||
Headers: map[string][]string{
|
||||
|
||||
358
shortcuts/sheets/batch_key_vocab_test.go
Normal file
358
shortcuts/sheets/batch_key_vocab_test.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// subOp builds a raw +batch-update sub-op for translateBatchOp tests.
|
||||
func subOp(shortcut string, input map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{"shortcut": shortcut, "input": input}
|
||||
}
|
||||
|
||||
// TestBatchOp_UnknownInputKeyRejected pins the key-vocabulary guard: an
|
||||
// off-vocabulary sub-op input key must error with a did-you-mean instead of
|
||||
// being silently ignored (silent ignore surfaced as misleading "missing
|
||||
// required flag" errors — the top batch error cluster in eval traces).
|
||||
func TestBatchOp_UnknownInputKeyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invented key errors with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"rangee": "A1:B2",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}), testToken, 0)
|
||||
ve := requireValidation(t, err, `unknown input key "rangee"`)
|
||||
if !strings.Contains(ve.Message, `did you mean "range"`) {
|
||||
t.Fatalf("message %q missing did-you-mean", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "input keys:") {
|
||||
t.Fatalf("hint %q missing key contract", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system flag is not sub-op vocabulary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"dry_run": true,
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, `unknown input key "dry_run"`)
|
||||
})
|
||||
|
||||
t.Run("reserved locator in hyphen form still rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"spreadsheet-token": "shtXXX",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "do not pass input.spreadsheet-token")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOp_HabitualKeysRewritten pins the silent rewrites: camelCase onto
|
||||
// the declared flag, and the commandFlagAliases table (size → width/height on
|
||||
// the resize pair — the pre-2026-07 vocabulary and the styles-protocol
|
||||
// spelling, the single largest sub-op error cluster).
|
||||
func TestBatchOp_HabitualKeysRewritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("camelCase sheetName resolves", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheetName": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "S1" {
|
||||
t.Fatalf("sheet_name = %v, want S1", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to width on +cols-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cols-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A:C",
|
||||
"type": "pixel",
|
||||
"size": float64(120),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
width, _ := input["resize_width"].(map[string]interface{})
|
||||
if width["value"] != 120 {
|
||||
t.Fatalf("resize_width = %v, want value 120", input["resize_width"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("size aliases to height on +rows-resize", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+rows-resize", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "1:3",
|
||||
"type": "pixel",
|
||||
"size": float64(36),
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single-entry ranges unwraps onto range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:B2" {
|
||||
t.Fatalf("range = %v, want A1:B2", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-entry ranges prescribes a split", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"A1:B2", "C1:D2"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "split them into 2 sub-ops")
|
||||
})
|
||||
|
||||
// A variant next to its canonical key must reject, not silently overwrite:
|
||||
// keys iterate in sorted order, so the variant's rewrite would land after
|
||||
// the canonical value was already accepted and clobber it.
|
||||
t.Run("camelCase variant alongside canonical rejects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheetName": "shadow",
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "got both")
|
||||
})
|
||||
|
||||
t.Run("ranges alongside range rejects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"range": "A1:B2",
|
||||
"ranges": []interface{}{"C1:D2"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "got both")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOperations_AggregatesValidationErrors pins the one-pass contract:
|
||||
// several invalid ops come back in a single error (each with its own
|
||||
// operations[i] context) instead of the first only — eval traces show
|
||||
// fix-one-resend loops of up to 7 round trips under first-error-only.
|
||||
func TestBatchOperations_AggregatesValidationErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops both reported", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-clear", map[string]interface{}{"range": "A1:B2"}), // missing sheet selector
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}), // missing cells
|
||||
subOp("+cells-clear", map[string]interface{}{"sheet_name": "S1", "range": "A1:B2"}), // valid
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"operations[0] (+cells-clear)", "operations[1] (+cells-set)", "--cells is required"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps the standalone-shaped error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOperations([]interface{}{
|
||||
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}),
|
||||
}, testToken)
|
||||
ve := requireValidation(t, err, "--cells is required")
|
||||
if strings.Contains(ve.Message, "failed validation") {
|
||||
t.Fatalf("single-error message must not use the aggregate wrapper: %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSetInput_MatrixPrecheck pins the local cells-vs-range guard that
|
||||
// front-runs the server's mid-batch "does not match range" failures.
|
||||
func TestCellsSetInput_MatrixPrecheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
wantContains string // "" = expect success
|
||||
}{
|
||||
{
|
||||
"empty cells prescribes +cells-clear",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2", "cells": []interface{}{}},
|
||||
"+cells-clear",
|
||||
},
|
||||
{
|
||||
"row count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 1 rows but --range \"A1:B3\" spans 3 rows",
|
||||
},
|
||||
{
|
||||
"column count mismatch",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"has 1 columns but --range \"A1:B1\" spans 2 columns",
|
||||
},
|
||||
{
|
||||
"matching matrix passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
[]interface{}{map[string]interface{}{"value": "c"}, map[string]interface{}{"value": "d"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"bare single-cell range enforces the 1x1 match (07-21: server rejects anchors too)",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
|
||||
}},
|
||||
"has 2 columns but --range \"A1\" spans 1 columns",
|
||||
},
|
||||
{
|
||||
"single-cell range with a single cell passes",
|
||||
map[string]interface{}{"sheet_name": "S1", "range": "B3",
|
||||
"cells": []interface{}{
|
||||
[]interface{}{map[string]interface{}{"value": "a"}},
|
||||
}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", tc.input), testToken, 0)
|
||||
if tc.wantContains == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenToolErrorMsg_PartialFailureRecovery pins the no-rollback recovery
|
||||
// prescription appended to server-side "N succeeded, M failed" errors.
|
||||
func TestFlattenToolErrorMsg_PartialFailureRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrap := func(inner string) string {
|
||||
return `{"error":` + jsonQuote(inner) + `}`
|
||||
}
|
||||
|
||||
t.Run("single failure prescribes resend-from-index", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 4 succeeded, 1 failed","failures":[{"index":4,"tool_name":"set_cell_range","error":"cells is required"}]}`), false, true)
|
||||
for _, want := range []string{"operations[4] (set_cell_range)", "no rollback", "resend only operations[4:]"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("msg %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple failures prescribe failed-only resend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 3 succeeded, 2 failed","failures":[{"index":1,"tool_name":"set_cell_range","error":"e1"},{"index":3,"tool_name":"resize_range","error":"e2"}]}`), false, true)
|
||||
if !strings.Contains(msg, "resend only the failed operations") {
|
||||
t.Fatalf("msg %q missing failed-only prescription", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero succeeded gets no note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 0 succeeded, 1 failed","failures":[{"index":0,"tool_name":"set_cell_range","error":"e"}]}`), false, true)
|
||||
if strings.Contains(msg, "no rollback") {
|
||||
t.Fatalf("msg %q must not carry the note when nothing was applied", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// jsonQuote wraps s as a JSON string literal (escaping quotes), mirroring how
|
||||
// the server double-encodes the inner error payload.
|
||||
func jsonQuote(s string) string {
|
||||
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
|
||||
}
|
||||
|
||||
// TestBatchOp_SpellingConflictRejected pins the uniqueness half of key
|
||||
// canonicalization: two accepted spellings of the same logical flag must not
|
||||
// both survive into the tool body. The flag view resolves hyphen↔underscore
|
||||
// variants, so a leftover duplicate is silently shadowed — with a sheet
|
||||
// selector that means the write lands on whichever spelling won, and the other
|
||||
// value disappears without a word.
|
||||
func TestBatchOp_SpellingConflictRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("conflicting values reject", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-id": "first",
|
||||
"sheet_id": "second",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "conflicting values")
|
||||
})
|
||||
|
||||
t.Run("identical values under two spellings pass and collapse to one key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-id": "same",
|
||||
"sheet_id": "same",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_id"] != "same" {
|
||||
t.Fatalf("sheet_id = %v, want same", input["sheet_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hyphen spelling alone is normalized to the underscore form", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
|
||||
"sheet-name": "S1",
|
||||
"range": "A1:B2",
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "S1" {
|
||||
t.Fatalf("sheet_name = %v, want S1 (hyphen spelling should canonicalize)", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -93,6 +93,15 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
|
||||
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
|
||||
},
|
||||
{
|
||||
// The both-axes form has to hold inside a batch too: it is the only
|
||||
// way to freeze rows AND columns there, since +styles-put (the other
|
||||
// carrier of a combined freeze) is not a batchable sub-op.
|
||||
shortcut: "+dim-freeze",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--sheet-id", "sh1", "--rows", "1", "--cols", "2"},
|
||||
subInput: `{"sheet-id":"sh1","rows":1,"cols":2}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-group",
|
||||
sc: DimGroup,
|
||||
@@ -763,7 +772,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
{
|
||||
"+pivot-create summarize_by out of enum",
|
||||
"+pivot-create",
|
||||
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
`{"target_sheet_id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
"summarize_by",
|
||||
},
|
||||
// +chart-create properties.position.row has minimum:0 — P0
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
|
||||
@@ -84,7 +87,14 @@ func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
|
||||
var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 单元格内容 ──────────────────────────────────────────────────
|
||||
"+cells-set": {"set_cell_range", cellsSetInput},
|
||||
"+cells-set": {"set_cell_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --writes plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range+cells each.
|
||||
if fv.Changed("writes") {
|
||||
return nil, sheetsValidationForFlag("writes", `"writes" is not supported inside +batch-update (it expands into its own batch request); call +cells-set --writes standalone, or give each sub-op a single range + cells`)
|
||||
}
|
||||
return cellsSetInput(fv, token, sid, sname)
|
||||
}},
|
||||
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
|
||||
"+cells-clear": {"clear_cell_range", cellsClearInput},
|
||||
"+cells-replace": {"replace_data", replaceInput},
|
||||
@@ -102,6 +112,11 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
|
||||
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
|
||||
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
// The --ranges plural form expands into its own atomic batch and
|
||||
// cannot nest; sub-ops carry one range each.
|
||||
if fv.Changed("ranges") {
|
||||
return nil, sheetsValidationForFlag("ranges", `"ranges" is not supported inside +batch-update (it expands into its own batch request); call +dim-delete --ranges standalone, or give each sub-op a single "range"`)
|
||||
}
|
||||
return dimRangeOpInput(fv, token, sid, sname, "delete")
|
||||
}},
|
||||
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
@@ -301,6 +316,198 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str
|
||||
// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。
|
||||
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
|
||||
// wrappedSubOpInputKeys are nested MCP-body container keys that must never
|
||||
// appear at a sub-op input's top level — their presence means the caller
|
||||
// pasted a shortcut's structured *output* (e.g. a {"cell_styles":{…}} block)
|
||||
// where the flattened flag keys belong. None of the batch sub-op translators
|
||||
// read input under these names, so rejecting them is safe.
|
||||
var wrappedSubOpInputKeys = []string{"cell_styles", "cell_merges", "styles"}
|
||||
|
||||
// subOpKeyVocabulary returns the set of hyphen-canonical flag names a sub-op
|
||||
// input may carry for `sc`: every non-system flag in flag-defs except the
|
||||
// spreadsheet locators (reserved for the batch top level). Nil when the
|
||||
// shortcut has no flag-defs entry (vocabulary checks are then skipped).
|
||||
func subOpKeyVocabulary(sc string) map[string]bool {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[sc]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
vocab := make(map[string]bool, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Name == "url" || df.Name == "spreadsheet-token" {
|
||||
continue
|
||||
}
|
||||
vocab[df.Name] = true
|
||||
}
|
||||
return vocab
|
||||
}
|
||||
|
||||
// camelToKebab converts a lowerCamelCase key to its kebab form
|
||||
// (sheetName → sheet-name). Returns "" when the key carries no uppercase
|
||||
// letter (nothing to convert).
|
||||
func camelToKebab(key string) string {
|
||||
if strings.ToLower(key) == key {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, r := range key {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r + ('a' - 'A'))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeSubOpInputKeys validates every sub-op input key against the
|
||||
// shortcut's flag vocabulary, rewriting habitual spellings in place and
|
||||
// rejecting anything that matches nothing. Eval traces show unknown keys were
|
||||
// previously ignored silently, which turned "wrong key" (size for width,
|
||||
// camelCase sheetName, an invented styles object) into misleading
|
||||
// "missing required flag" errors downstream — the single largest batch error
|
||||
// cluster. Rewrites applied, in order:
|
||||
//
|
||||
// - underscore ↔ hyphen forms of a declared flag (already tolerated by
|
||||
// mapFlagView — accepted here as-is)
|
||||
// - lowerCamelCase → the declared flag (sheetName → sheet_name)
|
||||
// - the command's intuitive-alias table (size → width/height on the resize
|
||||
// pair) — the same commandFlagAliases the cobra path applies
|
||||
// - "ranges" with a single-entry array unwraps onto "range"; a multi-entry
|
||||
// array gets a split-into-sub-ops prescription instead
|
||||
//
|
||||
// Anything else errors with a did-you-mean. Returns a bare error; the caller
|
||||
// wraps it with the operations[i] (<shortcut>) context and key contract.
|
||||
func normalizeSubOpInputKeys(sc string, input map[string]interface{}) error {
|
||||
vocab := subOpKeyVocabulary(sc)
|
||||
if vocab == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(input))
|
||||
for k := range input {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
aliases := commandFlagAliases[sc]
|
||||
// canonical tracks which raw key already claimed each logical key, so two
|
||||
// spellings of the same flag (sheet-id / sheet_id / sheetId) can never both
|
||||
// survive into the tool body — the flag view resolves hyphen↔underscore
|
||||
// variants, so a leftover duplicate would be silently shadowed and could
|
||||
// send the write to the wrong sheet.
|
||||
canonical := map[string]string{}
|
||||
claim := func(logical, raw string) error {
|
||||
if prev, taken := canonical[logical]; taken {
|
||||
if jsonEqual(input[prev], input[raw]) {
|
||||
return nil // same value under two spellings: harmless
|
||||
}
|
||||
return fmt.Errorf("%s got conflicting values for %q under two spellings (%q and %q) — keep one", sc, strings.ReplaceAll(logical, "-", "_"), prev, raw) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
canonical[logical] = raw
|
||||
return nil
|
||||
}
|
||||
for _, k := range keys {
|
||||
hv := strings.ReplaceAll(k, "_", "-")
|
||||
if vocab[hv] {
|
||||
if err := claim(hv, k); err != nil {
|
||||
return err
|
||||
}
|
||||
// Normalize the surviving spelling to the underscore form the tool
|
||||
// bodies use, so exactly one key reaches the flag view.
|
||||
if target := strings.ReplaceAll(hv, "-", "_"); target != k {
|
||||
if _, taken := input[target]; !taken {
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
canonical[hv] = target
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if kebab := camelToKebab(k); kebab != "" && vocab[kebab] {
|
||||
if err := claim(kebab, k); err != nil {
|
||||
return err
|
||||
}
|
||||
target := strings.ReplaceAll(kebab, "-", "_")
|
||||
if _, taken := input[target]; taken {
|
||||
return fmt.Errorf("%s got both %q and %q — keep %q and drop the other", sc, k, target, target) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if _, taken := input[kebab]; taken && kebab != target {
|
||||
return fmt.Errorf("%s got both %q and %q — keep %q and drop the other", sc, k, kebab, kebab) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
canonical[kebab] = target
|
||||
continue
|
||||
}
|
||||
if target, ok := aliases[strings.ToLower(hv)]; ok && vocab[target] {
|
||||
if err := claim(target, k); err != nil {
|
||||
return err
|
||||
}
|
||||
underscored := strings.ReplaceAll(target, "-", "_")
|
||||
_, hyphenTaken := input[target]
|
||||
_, underscoreTaken := input[underscored]
|
||||
if !hyphenTaken && !underscoreTaken {
|
||||
input[target] = input[k]
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
// The alias AND its target are both present. This key is recognized,
|
||||
// so it must not fall through to the generic "unknown input key"
|
||||
// below — the claim() conflict message never fires here either,
|
||||
// because keys are walked in sorted order and the alias can sort
|
||||
// before its target ("size" < "width"), so nothing has claimed the
|
||||
// logical key yet. Name both spellings and the survivor.
|
||||
taken := target
|
||||
if underscoreTaken {
|
||||
taken = underscored
|
||||
}
|
||||
if jsonEqual(input[k], input[taken]) {
|
||||
delete(input, k) // same value under two names: drop the alias.
|
||||
// Hand the logical key over to the surviving spelling, or the
|
||||
// claim recorded above would still point at the deleted alias
|
||||
// and make that spelling's own turn read as a conflict.
|
||||
canonical[target] = taken
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s got both %q and %q, which are two names for the same flag, with different values — keep %q", sc, k, taken, taken) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if strings.ToLower(hv) == "ranges" && vocab["range"] && !vocab["ranges"] {
|
||||
if _, taken := input["range"]; taken {
|
||||
return fmt.Errorf("%s got both %q and \"range\" — keep \"range\" and drop %q", sc, k, k) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if arr, isArr := input[k].([]interface{}); isArr {
|
||||
if len(arr) == 1 {
|
||||
if s, isStr := arr[0].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s takes a single \"range\" per sub-op, got %d entries in %q — split them into %d sub-ops (one per range)", sc, len(arr), k, len(arr)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
if s, isStr := input[k].(string); isStr {
|
||||
input["range"] = s
|
||||
delete(input, k)
|
||||
continue
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("unknown input key %q", k)
|
||||
display := make([]string, 0, len(vocab))
|
||||
for name := range vocab {
|
||||
display = append(display, strings.ReplaceAll(name, "-", "_"))
|
||||
}
|
||||
sort.Strings(display)
|
||||
if match := suggest.Closest(strings.ToLower(hv), display, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
|
||||
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
|
||||
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
|
||||
@@ -312,6 +519,7 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
// - input 不是 object
|
||||
// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch)
|
||||
// - input 里手填了 excel_id / spreadsheet_token / url
|
||||
// - input 顶层出现 cell_styles / cell_merges / styles(误贴 MCP body 包裹结构)
|
||||
// - 子操作的 translator 报错(如缺必填字段)
|
||||
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
@@ -335,7 +543,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
"(read ops / fan-out wrappers like +batch-update / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
|
||||
index, sc,
|
||||
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
|
||||
}
|
||||
@@ -358,11 +566,30 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
)
|
||||
}
|
||||
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
|
||||
for _, k := range reservedSubOpKeys {
|
||||
// 连字符 / 下划线两种写法都算命中(spreadsheet-token 与 spreadsheet_token 同罪)。
|
||||
for userKey := range input {
|
||||
normalized := strings.ReplaceAll(userKey, "-", "_")
|
||||
for _, k := range reservedSubOpKeys {
|
||||
if normalized == k {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
index, sc, userKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reject a "wrapped structure" sub-op input: agents copy a shortcut's nested
|
||||
// output container (e.g. +workbook-create --styles' {"cell_styles":{…}}) into
|
||||
// the op input, but the op input is the shortcut's own flags flattened into
|
||||
// JSON keys, not that wrapper. Left unflagged this surfaces far downstream as
|
||||
// an unrelated "at least one style flag is required" (helpers.go), which never
|
||||
// points at the real mistake.
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
if _, has := input[k]; has {
|
||||
return nil, sheetsValidationForFlag(
|
||||
"operations",
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
`operations[%d] (%s): op input is the shortcut's flags flattened as JSON keys (e.g. "background_color": "#EBF1F8"); do not wrap in %s`,
|
||||
index, sc, k,
|
||||
)
|
||||
}
|
||||
@@ -373,6 +600,16 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
}
|
||||
}
|
||||
// Reject / rewrite off-vocabulary input keys BEFORE any value reads: an
|
||||
// unknown key silently ignored surfaces later as a misleading
|
||||
// "missing required flag" error (the top batch error cluster in evals).
|
||||
if err := normalizeSubOpInputKeys(sc, input); err != nil {
|
||||
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
|
||||
if contract := subOpInputContract(sc); contract != "" {
|
||||
verr = verr.WithHint("%s input keys: %s", sc, contract)
|
||||
}
|
||||
return nil, verr
|
||||
}
|
||||
fv := newMapFlagViewForCommand(sc, input)
|
||||
// operations is skipped by parse-time schema validation, so type-check the
|
||||
// sub-op's scalar fields here before the translator reads them via
|
||||
@@ -410,7 +647,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
|
||||
// matrix, on the operations axis.
|
||||
const maxBatchOperations = 100
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
// batchOpErrorDisplayLimit bounds how many per-op validation failures ride
|
||||
// on one aggregated --operations error, mirroring the schema validator's
|
||||
// display cap.
|
||||
const batchOpErrorDisplayLimit = 5
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组。逐 op 校验并**收集全部失败**
|
||||
// 一次性返回(不再 fail-fast)——agent 一轮就能修完所有坏 op,而不是
|
||||
// 修一个、重试、再撞下一个。cell 安全上限仍是全局判定,命中即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||||
@@ -422,10 +666,15 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
var totalCells int64
|
||||
var opErrs []error
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
opErrs = append(opErrs, err)
|
||||
continue
|
||||
}
|
||||
if len(opErrs) > 0 {
|
||||
continue // already failing — keep scanning for more bad ops, skip cell math.
|
||||
}
|
||||
totalCells += translatedCellCount(translated)
|
||||
if totalCells > maxStampMatrixCells {
|
||||
@@ -435,7 +684,31 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
switch len(opErrs) {
|
||||
case 0:
|
||||
return out, nil
|
||||
case 1:
|
||||
return nil, opErrs[0] // single failure keeps the historical error byte-for-byte.
|
||||
}
|
||||
shown := opErrs
|
||||
truncated := false
|
||||
if len(shown) > batchOpErrorDisplayLimit {
|
||||
shown = shown[:batchOpErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
// aggregatedIssueText keeps each op's own hint (the "<shortcut> input
|
||||
// keys: …" contract) inline: folding N errors leaves one Hint slot, so
|
||||
// without this the multi-op error would carry LESS guidance than the
|
||||
// single-op one it replaces.
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, aggregatedIssueText(e)))
|
||||
}
|
||||
msg := fmt.Sprintf("%d of %d operations failed validation: %s", len(opErrs), len(rawOps), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
msg += fmt.Sprintf("; (%d more not shown — fix these first)", len(opErrs)-batchOpErrorDisplayLimit)
|
||||
}
|
||||
return nil, sheetsValidationForFlag("operations", "%s", msg).WithCause(opErrs[0])
|
||||
}
|
||||
|
||||
func translatedCellCount(op map[string]interface{}) int64 {
|
||||
|
||||
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
113
shortcuts/sheets/cells_set_writes_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCellsSetWrites pins the --writes plural form: scattered (cross-sheet)
|
||||
// regions fan into ONE atomic batch_update, each item self-carrying its
|
||||
// sheet selector (no top-level fallback — same convention as +batch-update
|
||||
// sub-ops and +styles-put items), with per-item errors aggregated.
|
||||
func TestCellsSetWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writes := func(items string, extra ...string) (string, string, error) {
|
||||
args := append([]string{
|
||||
"--url", testURL, "--dry-run", "--writes", items,
|
||||
}, extra...)
|
||||
return runShortcutCapturingErr(t, CellsSet, args)
|
||||
}
|
||||
|
||||
t.Run("cross-sheet items expand into one batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[
|
||||
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
|
||||
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(C:C)"}]]}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"batch_update", "明细", "汇总", "IFERROR"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Fatalf("dry-run body missing %q: %s", want, stdout[:min(len(stdout), 400)])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item without sheet selector errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
requireValidation(t, err, "sheet-id or --sheet-name")
|
||||
})
|
||||
|
||||
t.Run("top-level sheet selector rejected with prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--sheet-name", "S1")
|
||||
requireValidation(t, err, "put sheet_name (or sheet_id) inside each writes item")
|
||||
})
|
||||
|
||||
t.Run("writes and range are mutually exclusive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--range", "A1")
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("per-item errors aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Both items pass the --writes schema (range+cells present) but fail
|
||||
// deeper: item 0 a matrix mismatch, item 1 a missing sheet selector.
|
||||
_, _, err := writes(`[
|
||||
{"sheet_name":"S1","range":"A1:B2","cells":[[{"value":"x"}]]},
|
||||
{"range":"C1","cells":[[{"value":"y"}]]}
|
||||
]`)
|
||||
ve := requireValidation(t, err, "--writes has 2 issues")
|
||||
for _, want := range []string{"--writes[0]", "--writes[1]", "sheet-name"} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Fatalf("message %q missing %q", ve.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("item keys go through the vocabulary layer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := writes(`[{"sheetName":"S1","range":"A1","cells":[[{"value":"x"}]]}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("camelCase sheetName must normalize: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "S1") {
|
||||
t.Fatalf("normalized item missing sheet: %s", stdout[:min(len(stdout), 300)])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cannot nest inside batch-update", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"writes": []interface{}{map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1", "cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
|
||||
}},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
|
||||
t.Run("styles flag gets the layering prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Ergonomics (FlagErrorFunc hints) mount via the registry, not the
|
||||
// bare shortcut var — mirror the real CLI wiring.
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL, "--dry-run",
|
||||
"--writes", `[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
|
||||
"--styles", `{"styles":[]}`,
|
||||
})
|
||||
ve := requireValidation(t, err, "unknown flag")
|
||||
if !strings.Contains(ve.Hint, "+styles-put") || !strings.Contains(ve.Hint, "cell_styles") {
|
||||
t.Fatalf("want the styles-put layering hint, got hint=%q", ve.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
149
shortcuts/sheets/chart_examples.go
Normal file
149
shortcuts/sheets/chart_examples.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─── +chart-create --print-example ─────────────────────────────────────
|
||||
//
|
||||
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
|
||||
// traces show agents paging through the full --print-schema dump for every
|
||||
// chart (25 round trips in one 35-task batch) and still missing deep
|
||||
// required fields. A ready-to-edit minimal template per chart type answers
|
||||
// the actual question ("what does a valid payload look like") in one local
|
||||
// call. Wired through PostMount, same pattern as +csv-put's flag-group
|
||||
// tweaks — no framework change.
|
||||
//
|
||||
// Templates mirror the canonical examples in the lark-sheets-chart
|
||||
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
|
||||
// inline headerMode with refs covering the header row, 1-based indices,
|
||||
// quoted sheet prefix in refs.
|
||||
|
||||
var chartExampleTemplates = map[string]string{
|
||||
"column": chartSimpleExample("column"),
|
||||
"bar": chartSimpleExample("bar"),
|
||||
"line": chartSimpleExample("line"),
|
||||
"area": chartSimpleExample("area"),
|
||||
"radar": chartSimpleExample("radar"),
|
||||
"scatter": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": "scatter"}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B20"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"pie": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 450},
|
||||
"snapshot": {
|
||||
"title": {"text": "占比标题"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "pie",
|
||||
"series": [{
|
||||
"index": 1,
|
||||
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
|
||||
}]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:B11"}],
|
||||
"dim1": {"serie": {"index": 1, "aggregate": true}},
|
||||
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"combo": `{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 700, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "柱线组合"},
|
||||
"plotArea": {"plot": {
|
||||
"type": "combo",
|
||||
"series": [
|
||||
{"index": 2, "comboType": "column"},
|
||||
{"index": 3, "comboType": "line"}
|
||||
]
|
||||
}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C13"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
// chartSimpleExample renders the shared minimal shape for plot types that
|
||||
// need nothing beyond plot.type (column / bar / line / area / radar).
|
||||
func chartSimpleExample(typ string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"position": {"row": 1, "col": "F"},
|
||||
"size": {"width": 600, "height": 400},
|
||||
"snapshot": {
|
||||
"title": {"text": "图表标题"},
|
||||
"plotArea": {"plot": {"type": %q}},
|
||||
"data": {
|
||||
"refs": [{"value": "'Sheet1'!A1:C10"}],
|
||||
"dim1": {"serie": {"index": 1}},
|
||||
"dim2": {"series": [{"index": 2}, {"index": 3}]}
|
||||
}
|
||||
}
|
||||
}`, typ)
|
||||
}
|
||||
|
||||
func chartExampleTypes() []string {
|
||||
types := make([]string, 0, len(chartExampleTemplates))
|
||||
for t := range chartExampleTemplates {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// withChartPrintExample wraps +chart-create's PostMount so --print-example
|
||||
// short-circuits execution and prints a minimal ready-to-edit --properties
|
||||
// template — purely local, no identity or network. The flag itself is
|
||||
// declared in flag-defs.json like every other own flag (so it shows up in the
|
||||
// generated reference tables); only the interception lives here.
|
||||
// --properties' cobra-level required annotation is relaxed (the input builder
|
||||
// still enforces it on the real path, same trick as +csv-put's --csv).
|
||||
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
// Only --properties carries a cobra-level required annotation (the
|
||||
// locator flags are xor pairs, enforced later); the input builder
|
||||
// still errors "--properties is required" on the real path.
|
||||
if fl := cmd.Flags().Lookup("properties"); fl != nil {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
prevRunE := cmd.RunE
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
typ, _ := c.Flags().GetString("print-example")
|
||||
if typ == "" {
|
||||
return prevRunE(c, args)
|
||||
}
|
||||
tmpl, ok := chartExampleTemplates[typ]
|
||||
if !ok {
|
||||
return common.ValidationErrorf("no example for chart type %q; available: %s",
|
||||
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
|
||||
}
|
||||
fmt.Fprintln(c.OutOrStdout(), tmpl)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
109
shortcuts/sheets/chart_examples_test.go
Normal file
109
shortcuts/sheets/chart_examples_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChartPrintExample pins the --print-example contract: a known type
|
||||
// prints its template and skips execution entirely; an unknown type lists
|
||||
// the available ones.
|
||||
func TestChartPrintExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("prints template without locator flags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
parent, _, _, _ := newTestRig(t, sc)
|
||||
var buf bytes.Buffer
|
||||
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
|
||||
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("print-example should run standalone, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), `"sectors"`) {
|
||||
t.Errorf("pie template should carry sectors, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown type lists available", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+chart-create")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
|
||||
ve := requireValidation(t, err, `no example for chart type "donut"`)
|
||||
if !strings.Contains(ve.Message, "pie") {
|
||||
t.Errorf("message should list available types, got %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--print-example" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--print-example")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
|
||||
// template against the embedded chart-create properties schema — a template
|
||||
// the CLI itself would reject is worse than none.
|
||||
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
for typ, tmpl := range chartExampleTemplates {
|
||||
t.Run(typ, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
|
||||
t.Fatalf("template is not valid JSON: %v", err)
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
|
||||
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
|
||||
t.Errorf("template rejected by embedded schema: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeChartHexColors_Arrays pins color normalization inside arrays:
|
||||
// the chart schema uses colorTheme / colorScale / highlight_colors, whose
|
||||
// values are LISTS of bare hex strings. Recursing without the key context
|
||||
// dropped the "#" prefix and the server rejected a payload its own schema
|
||||
// allows.
|
||||
func TestNormalizeChartHexColors_Arrays(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"colorTheme": []interface{}{"4472C4", "ED7D31"},
|
||||
"highlight_colors": []interface{}{"FF0000"},
|
||||
"colorScale": []interface{}{map[string]interface{}{"color": "70AD47"}},
|
||||
"backgroundColor": "4472C4",
|
||||
"colorMode": "auto",
|
||||
"title": []interface{}{"4472C4"},
|
||||
}
|
||||
raw, err := json.Marshal(normalizeChartHexColors(in))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
theme := got["colorTheme"].([]interface{})
|
||||
if theme[0] != "#4472C4" || theme[1] != "#ED7D31" {
|
||||
t.Errorf("colorTheme = %v, want both prefixed", theme)
|
||||
}
|
||||
if got["highlight_colors"].([]interface{})[0] != "#FF0000" {
|
||||
t.Errorf("highlight_colors = %v", got["highlight_colors"])
|
||||
}
|
||||
if got["colorScale"].([]interface{})[0].(map[string]interface{})["color"] != "#70AD47" {
|
||||
t.Errorf("colorScale = %v", got["colorScale"])
|
||||
}
|
||||
// Non-hex values under a color-ish key, and hex-looking values under a
|
||||
// non-color key, must both be left alone.
|
||||
if got["colorMode"] != "auto" {
|
||||
t.Errorf("colorMode = %v, want untouched", got["colorMode"])
|
||||
}
|
||||
if got["title"].([]interface{})[0] != "4472C4" {
|
||||
t.Errorf("title = %v, want untouched (not a color key)", got["title"])
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ func newCSVGuardRuntime(csvVal string) *common.RuntimeContext {
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value
|
||||
// only when it names a real file (a forgotten @), while leaving genuine inline
|
||||
// content alone — including the case the old name-shape heuristic got wrong:
|
||||
// prose that merely ends in or mentions a filename.
|
||||
// TestGuardCSVValueIsNotFilePath covers the existing-file tier: a bare --csv
|
||||
// value naming a real file is a forgotten "@". The prescription names the fix
|
||||
// with a <path> placeholder — the untrusted value must not be spliced into
|
||||
// command-shaped text an agent would copy verbatim.
|
||||
func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
@@ -33,23 +33,98 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bare value naming an existing file → guarded with a fix-it hint.
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
|
||||
ve := requireValidation(t, err, "existing file")
|
||||
if !strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
|
||||
if !strings.Contains(ve.Message, `"data.csv"`) {
|
||||
t.Errorf("message should name the offending value as data, got: %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, "--csv @<path>") {
|
||||
t.Errorf("message should prescribe the @ form via placeholder, got: %q", ve.Message)
|
||||
}
|
||||
if strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message must not splice the value into a command fragment, got: %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--csv" {
|
||||
t.Errorf("param = %q, want --csv", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_MissingButPathShaped covers the second tier.
|
||||
// A path that doesn't resolve used to pass through and be written into the
|
||||
// cell verbatim — a wrong value with a success exit code. The common source is
|
||||
// an absolute path: `@` rejects those, so the caller drops the `@` and retries.
|
||||
// Since the file can't be read from cwd, the prescription is stdin.
|
||||
func TestGuardCSVValueIsNotFilePath_MissingButPathShaped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// Content that is not a real file must pass through unchanged.
|
||||
for _, v := range []string{
|
||||
"改完记得更新config.json", // prose ending in a filename — not a real file
|
||||
"remember to update data.csv", // mentions the real file but isn't its name
|
||||
"nope.csv", // relative path from another working directory
|
||||
"./missing.csv", // explicit relative prefix
|
||||
"../sibling/x.tsv", // parent-relative
|
||||
"/tmp/nope.csv", // absolute — the `@`-rejected case
|
||||
"~/data.tsv", // home-relative
|
||||
"/var/tmp/export", // no extension, but an unmistakable path prefix
|
||||
"C:/Users/me/a.csv", // windows-style, still ASCII path shape
|
||||
} {
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v))
|
||||
ve := requireValidation(t, err, "looks like a file path")
|
||||
if !strings.Contains(ve.Hint, "--csv @") || !strings.Contains(ve.Hint, "--csv - <") {
|
||||
t.Errorf("value %q: hint should offer both @file and stdin, got: %q", v, ve.Hint)
|
||||
}
|
||||
// The untrusted value must never appear inside the command-shaped
|
||||
// hint: "--csv - < $(id).csv" copied by an agent would expand in a
|
||||
// POSIX shell. The value is only named as quoted data in the message.
|
||||
if strings.Contains(ve.Hint, v) {
|
||||
t.Errorf("value %q: hint must not splice the raw value into a command fragment, got: %q", v, ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Message, v) {
|
||||
t.Errorf("value %q: message should still name the offending value, got: %q", v, ve.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_SkipsResolvedInput pins the origin rule that
|
||||
// makes the shape heuristic safe: a value that arrived via @file / stdin is
|
||||
// never inspected, however path-shaped its content — so the hint's promise
|
||||
// that stdin writes such text verbatim actually holds, and a correct
|
||||
// `--csv @file` invocation can't be re-rejected for its content.
|
||||
func TestGuardCSVValueIsNotFilePath_SkipsResolvedInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, v := range []string{
|
||||
"nope.csv", // path-shaped, missing — rejected when inline
|
||||
"data.csv", // names an existing file — rejected when inline
|
||||
} {
|
||||
rctx := newCSVGuardRuntime(v)
|
||||
common.TestMarkInputResolved(rctx, "csv")
|
||||
if err := guardCSVValueIsNotFilePath(rctx); err != nil {
|
||||
t.Errorf("resolved value %q must skip the guard, got: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardCSVValueIsNotFilePath_PassesThrough pins what must still reach the
|
||||
// sheet untouched. The prose cases are why the guard checks a narrow shape
|
||||
// instead of "contains a filename": an earlier name-shape heuristic rejected
|
||||
// them. "N/A" and "README.md" pin the two narrowing rules — a slash alone is
|
||||
// not a path, and a filename alone is not a CSV path.
|
||||
func TestGuardCSVValueIsNotFilePath_PassesThrough(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
for _, v := range []string{
|
||||
"改完记得更新config.json", // CJK prose ending in a filename
|
||||
"remember to update data.csv", // prose mentioning a file
|
||||
"a,b\n1,2", // multi-cell CSV
|
||||
"hello world",
|
||||
"nope.csv", // path-shaped but no such file
|
||||
"N/A", // slash, but no CSV extension and no path prefix
|
||||
"README.md", // filename shape, not a CSV one
|
||||
"report 2026.csv", // has a space: content, not a path
|
||||
"",
|
||||
} {
|
||||
if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,7 @@
|
||||
"+float-image-update",
|
||||
"+float-image-delete"
|
||||
],
|
||||
"description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 atomic batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete})一律禁。"
|
||||
"description": "CLI shortcut 名(不是底层 MCP tool 名)。+dim-move 不在表中——它走 legacy v2 endpoint,无法批;+cells-set-image / +workbook-create 也不在——前者含多步图片上传,后者是新建工作簿,都不属于 batch 范畴;所有读操作、fan-out wrapper(+batch-update 自身 / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete})一律禁——美化收尾请单独调 +styles-put,不要拆成子操作数组。"
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
@@ -648,6 +648,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"writes": {
|
||||
"type": "array",
|
||||
"description": "多区域写入项数组(最多 100 项),整批单次批量提交(fail-fast、不回滚);支持跨 sheet。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"range",
|
||||
"cells"
|
||||
],
|
||||
"properties": {
|
||||
"sheet_id": {
|
||||
"type": "string",
|
||||
"description": "目标子表 reference_id;与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)。"
|
||||
},
|
||||
"sheet_name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名;与 sheet_id 二选一,必须写在每一项里。"
|
||||
},
|
||||
"range": {
|
||||
"type": "string",
|
||||
"description": "A1 矩形范围,行列维度必须与 cells 严格一致(同 --range)。"
|
||||
},
|
||||
"cells": {
|
||||
"type": "array",
|
||||
"description": "二维单元格数组,结构同 --cells(value / formula / cell_styles / border_styles 等,见 set_cell_range#/properties/cells)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cells-set-style": {
|
||||
@@ -7748,87 +7777,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+styles-put": {
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
@@ -7856,12 +7805,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8055,7 +8008,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8073,19 +8026,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8104,8 +8070,395 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_family": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -8228,12 +8581,16 @@
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border": {
|
||||
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
|
||||
"type": "object"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
@@ -8427,7 +8784,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8445,19 +8802,32 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"freeze": {
|
||||
"description": "冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校验拒绝)。",
|
||||
"properties": {
|
||||
"cols": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"rows": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
@@ -8476,8 +8846,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
|
||||
@@ -50,7 +50,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style): `{ top|bottom|left|right|all: {style,weight,color} }`; style = solid|dashed|dotted|double|none, weight = thin|medium|thick (string), color = hex like #000000", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -59,8 +59,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
|
||||
@@ -72,11 +72,12 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -86,8 +87,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -98,8 +99,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
|
||||
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
|
||||
@@ -115,8 +116,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
@@ -133,10 +134,11 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two); not accepted with `--writes` (each writes item carries its own sheet selector)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "xor", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "writes", Kind: "own", Type: "string", Required: "xor", Desc: "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE batched request (fail-fast, no rollback), cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
|
||||
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
|
||||
@@ -148,8 +150,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
|
||||
@@ -161,8 +163,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
@@ -175,7 +177,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,weight,color}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides. style = line type (solid|dashed|dotted|double|none); weight = thickness (thin|medium|thick — a string, not a pixel number); color = hex like #000000. { all: {...} } sets all four sides at once. This is the only border flag: no --border-all / --border-top / --border-color exist", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -184,8 +186,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -204,9 +206,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "print-example", Kind: "own", Type: "string", Required: "optional", Desc: "Print a minimal ready-to-edit --properties template for a chart type (area|bar|column|combo|line|pie|radar|scatter) and exit. Purely local: no locator flags, no network; an unknown type lists the available ones"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
@@ -215,8 +218,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -227,8 +230,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -238,8 +241,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -250,10 +253,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"},
|
||||
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one batched call (fail-fast, no rollback). Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -264,8 +267,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
@@ -277,8 +280,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -289,8 +292,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -300,8 +303,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
@@ -314,10 +317,11 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Note the file is the data payload as JSON — on +csv-get too, where the CSV text sits in a field inside it — not a ready-to-use .csv; redirect stdout instead if you want a bare CSV file. Omit it to print to stdout as usual."},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
@@ -328,8 +332,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
@@ -342,9 +346,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "xor", Desc: "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one batched delete (fail-fast, no rollback) — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -354,10 +359,12 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "optional", Desc: "[legacy] Dimension (row or column), paired with --count; sets one axis only and unfreezes the other. Prefer --rows / --cols", Hidden: true, Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "[legacy] Freeze the first N rows/columns (paired with --dimension); 0 clears all freezing. Equivalent to --rows N / --cols N, and only --rows/--cols can hold both axes at once", Hidden: true},
|
||||
{Name: "rows", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N rows; together with --cols this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen rows)"},
|
||||
{Name: "cols", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N columns; together with --rows this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen columns)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -366,8 +373,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
|
||||
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
@@ -379,8 +386,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -390,9 +397,9 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.", Enum: []string{"before", "after"}},
|
||||
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -403,8 +410,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -415,8 +422,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -427,8 +434,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -448,8 +455,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -459,8 +466,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
@@ -489,8 +496,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -501,8 +508,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -512,8 +519,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -522,8 +529,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -534,8 +541,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
|
||||
@@ -547,8 +554,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -559,8 +566,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -570,8 +577,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
|
||||
@@ -584,8 +591,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"},
|
||||
@@ -605,8 +612,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -617,8 +624,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -628,8 +635,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`"},
|
||||
@@ -702,8 +709,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -714,8 +721,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -725,8 +732,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -737,8 +744,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
@@ -751,8 +758,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
|
||||
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
|
||||
@@ -764,8 +771,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
@@ -777,8 +784,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
|
||||
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
|
||||
@@ -798,10 +805,10 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"},
|
||||
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one batched call (fail-fast, no rollback). Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -812,8 +819,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -837,8 +844,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -848,8 +855,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -858,8 +865,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -868,8 +875,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -880,8 +887,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -892,8 +899,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -903,8 +910,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -914,8 +921,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -924,8 +931,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -934,8 +941,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -945,8 +952,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -957,8 +964,8 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -968,13 +975,22 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+styles-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (target sheets are named inside --styles items)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "required", Desc: "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one batched request (fail-fast, no rollback: applied sub-operations stay); ranges may target any region of the sheet", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the batched request template for each expanded operation; no network side effects"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -983,6 +999,8 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (cap auto-raises to a bounded 20M chars; explicit --max-chars overrides). Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
|
||||
// enum + default
|
||||
rt := byName("+dim-insert", "inherit-style")
|
||||
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
|
||||
if rt == nil || len(rt.Enum) != 2 || rt.Default != "" {
|
||||
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
|
||||
}
|
||||
// required
|
||||
|
||||
@@ -38,9 +38,134 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
}
|
||||
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
|
||||
chainEnumNormalization(cmd)
|
||||
chainFlagAliases(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
|
||||
//
|
||||
// Eval traces show unknown-flag failures cluster on a handful of habitual
|
||||
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
|
||||
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
|
||||
// the enum-normalization contract above: a name whose value semantics are
|
||||
// identical to the real flag is rewritten silently (zero round-trips); a
|
||||
// name whose fix changes the value or moves it into a JSON field gets a
|
||||
// curated prescription on the unknown-flag error instead — never a silent
|
||||
// rewrite.
|
||||
|
||||
// commandFlagAliases maps, per command, habitual flag names onto the flag
|
||||
// actually registered. Only pairs with identical value semantics belong
|
||||
// here: the rewrite is invisible, so it must be safe to apply unread
|
||||
// (+csv-put --file with a path value still trips the file-path guard, which
|
||||
// prescribes @file / stdin).
|
||||
var commandFlagAliases = map[string]map[string]string{
|
||||
"+csv-put": {"file": "csv"},
|
||||
"+sheet-create": {"name": "title"},
|
||||
// The new name is the only name-valued input a rename takes, so the
|
||||
// habitual spellings are unambiguous (unlike +sheet-copy, where a name
|
||||
// could mean the copy's title or the source selector and gets a
|
||||
// prescription instead). 07-28 root-cause report #25: 10/10 wrote
|
||||
// --new-name, 24 occurrences.
|
||||
"+sheet-rename": {"name": "title", "new-name": "title"},
|
||||
// size → width/height: the styles protocol (--styles row_sizes/col_sizes)
|
||||
// spells the pixel dimension "size", and pre-2026-07 batches accepted it
|
||||
// here too — the rename is the single largest sub-op error cluster in
|
||||
// eval traces (15+ hits). Same pixel-count semantics, safe to rewrite.
|
||||
"+cols-resize": {"cols": "range", "size": "width"},
|
||||
"+rows-resize": {"rows": "range", "size": "height"},
|
||||
"+range-fill": {"source": "source-range", "target": "target-range"},
|
||||
"+range-copy": {"source": "source-range", "target": "target-range"},
|
||||
"+range-move": {"source": "source-range", "target": "target-range"},
|
||||
}
|
||||
|
||||
// intuitiveFlagHints carries the prescription for habitual names whose fix
|
||||
// is not a 1:1 rename — the value belongs to a different flag or to a field
|
||||
// inside a JSON payload. The hint spells the exact correct form so the
|
||||
// retry needs no --help round trip.
|
||||
var intuitiveFlagHints = map[string]map[string]string{
|
||||
"+sheet-copy": {
|
||||
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
|
||||
},
|
||||
"+dim-insert": {
|
||||
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
|
||||
},
|
||||
// Must prescribe --rows / --cols, never the retired --dimension/--count
|
||||
// pair (DEPRECATED(phase-2) on dimFreezeLegacyNote): those flags are hidden
|
||||
// from --help, so they do not even appear in the "valid flags" list printed
|
||||
// beside this hint, and using them earns a second note steering back here.
|
||||
"+dim-freeze": {
|
||||
"frozen-rows": "freeze the first N rows with --rows N (add --cols M to hold columns too — one call states the whole freeze state)",
|
||||
"frozen-cols": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-columns": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-row-count": "freeze the first N rows with --rows N (add --cols M to hold columns too — one call states the whole freeze state)",
|
||||
"frozen-col-count": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
"frozen-column-count": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
|
||||
},
|
||||
"+cells-set-style": {
|
||||
"bold": "use --font-weight bold",
|
||||
"italic": "use --font-style italic",
|
||||
"underline": "use --font-line underline",
|
||||
"font-bold": "use --font-weight bold",
|
||||
"bg-color": "use --background-color",
|
||||
// Google Sheets API vocabulary (wrapStrategy).
|
||||
"wrap-strategy": "use --word-wrap (overflow / auto-wrap / word-clip)",
|
||||
// The border family: the only border flag is --border-styles (composite
|
||||
// JSON); color and per-side variants ride inside it.
|
||||
"border-style": `borders take one composite flag: --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right, or "all" for all four)`,
|
||||
"border-color": `border color rides inside --border-styles JSON, e.g. --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-all": `use --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}' — the "all" key applies one spec to all four sides`,
|
||||
"border-top": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-bottom": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-left": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"left":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
"border-right": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"right":{"style":"solid","weight":"thin","color":"#000000"}}'`,
|
||||
},
|
||||
"+cells-set": {
|
||||
// Predictable prior from +table-put --styles: models will try to
|
||||
// attach range-level styling to a --writes call the same way.
|
||||
"styles": `range-level styling goes through +styles-put (same {"styles":[...]} vocabulary); per-cell styles ride inside the cells objects as cell_styles`,
|
||||
// +workbook-create's untyped-data flag, carried over to the write
|
||||
// command (07-28 root-cause report #9, 63 occurrences; values↔cells
|
||||
// shares no prefix so edit distance never suggests the fix).
|
||||
"values": `cell contents go in --cells as a 2D array of cell objects ('[[{"value":…},…],…]'); --values is +workbook-create's flag for untyped initial data`,
|
||||
},
|
||||
"+table-put": {
|
||||
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
|
||||
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
|
||||
},
|
||||
}
|
||||
|
||||
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
|
||||
// (on top of any hook a prior PostMount installed, e.g. --token →
|
||||
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
|
||||
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
|
||||
// canonical name), and the command's intuitive-alias table. Either way a
|
||||
// habitual name parses as the real flag with zero round trips. Aliases
|
||||
// never shadow a registered flag and never appear in --help; an alias whose
|
||||
// target vanished (spec-side rename) is dropped, degrading to the
|
||||
// unknown-flag prescription.
|
||||
func chainFlagAliases(cmd *cobra.Command) {
|
||||
aliases := commandFlagAliases[cmd.Name()]
|
||||
usable := make(map[string]string, len(aliases))
|
||||
for alias, target := range aliases {
|
||||
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
|
||||
usable[alias] = target
|
||||
}
|
||||
}
|
||||
prev := cmd.Flags().GetNormalizeFunc()
|
||||
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if strings.Contains(name, "_") {
|
||||
name = strings.ReplaceAll(name, "_", "-")
|
||||
}
|
||||
if target, ok := usable[name]; ok {
|
||||
name = target
|
||||
}
|
||||
return prev(fs, name)
|
||||
})
|
||||
}
|
||||
|
||||
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
|
||||
// It keeps the root behavior (typed error, did-you-mean suggestions, the
|
||||
// offending flag on params) and additionally inlines the full valid-flag
|
||||
@@ -50,6 +175,19 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
|
||||
// immediately.
|
||||
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagFromParseError(ferr)
|
||||
// Targeted fix for a high-frequency agent mistake: +batch-update carries no
|
||||
// top-level sheet locator (each sub-op names its own sheet inside its input),
|
||||
// yet agents reach for --sheet-id / --sheet-name at the top level. An
|
||||
// edit-distance suggestion would only mislead here, so skip it and name the
|
||||
// real contract instead. Underscore spellings (--sheet_id) are matched too:
|
||||
// the error message itself teaches the underscore key names, and sub-op
|
||||
// inputs accept them, so agents mix the two styles.
|
||||
locatorName := strings.ReplaceAll(name, "_", "-")
|
||||
if isUnknown && c.Name() == "+batch-update" && (locatorName == "sheet-id" || locatorName == "sheet-name") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"batch-update has no top-level sheet locator; put sheet_id/sheet_name inside each operation's input").
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"})
|
||||
}
|
||||
if !isUnknown {
|
||||
return common.ValidationErrorf("%s", ferr.Error()).
|
||||
WithHint("run `%s --help` for valid flags", c.CommandPath())
|
||||
@@ -67,6 +205,21 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
|
||||
strings.Join(suggestions, ", "), list)
|
||||
}
|
||||
}
|
||||
// A curated prescription beats both: it spells the exact correct form
|
||||
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
|
||||
// Edit-distance candidates are dropped with it — they can contradict the
|
||||
// prescription (--font-bold ranked --font-color/--font-line/--font-size
|
||||
// while the fix is --font-weight), and a machine-readable suggestion that
|
||||
// disagrees with the hint sends agents down the wrong retry.
|
||||
// The map is keyed hyphenated but the parse error reports the flag as
|
||||
// typed, so --frozen_rows must hit the same entry as --frozen-rows.
|
||||
if rx, ok := intuitiveFlagHints[c.Name()][strings.ReplaceAll(name, "_", "-")]; ok {
|
||||
hint = rx
|
||||
if list := inlineFlagList(valid); list != "" {
|
||||
hint = rx + "; valid flags: " + list
|
||||
}
|
||||
suggestions = nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
|
||||
@@ -139,6 +292,69 @@ var enumAliases = map[string]string{
|
||||
"center": "middle", // CSS vertical-align: center → Lark "middle"
|
||||
"centre": "center",
|
||||
"middle": "center", // CSS-style middle → Lark horizontal "center"
|
||||
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
|
||||
// from the API docs; lowercased by canonicalEnumValue before lookup.
|
||||
"merge_all": "all",
|
||||
"merge_rows": "rows",
|
||||
"merge_columns": "columns",
|
||||
// Boolean-style word-wrap habits: true unambiguously means wrap on;
|
||||
// false means "don't wrap", whose Lark default is overflow (word-clip is
|
||||
// a distinct truncation mode nobody spells "false").
|
||||
"true": "auto-wrap",
|
||||
"false": "overflow",
|
||||
// Google Sheets wrapStrategy vocabulary: WRAP / CLIP / OVERFLOW. Only
|
||||
// the first two need mapping — overflow is spelled the same in both.
|
||||
"wrap": "auto-wrap",
|
||||
"clip": "word-clip",
|
||||
}
|
||||
|
||||
// DEPRECATED(phase-2): enum values this CLI used to accept and now expresses
|
||||
// by omitting the flag. They are dropped from the published enum so the docs
|
||||
// and --help stop teaching them, but a caller that still passes one must not
|
||||
// hard-fail: the value was valid — for --inherit-style it was even the
|
||||
// DEFAULT — so existing scripts and any agent carrying older docs would break
|
||||
// on a spelling that never meant anything else.
|
||||
//
|
||||
// Semantics: a retired value is cleared, making the call identical to omitting
|
||||
// the flag (pinned by TestRetiredEnumValueMatchesOmitted). It is deliberately
|
||||
// silent — unlike --dimension/--count there is nothing for the caller to
|
||||
// migrate to, so a note would only be noise.
|
||||
//
|
||||
// Phase 2 removal: drop the entry here and let the normal enum error apply.
|
||||
var retiredEnumValues = map[string]map[string][]string{
|
||||
// +dim-insert --inherit-style dropped "none" when the side mapping was
|
||||
// corrected: no inheritance is what omitting the flag already means, so
|
||||
// the value was pure redundancy.
|
||||
"+dim-insert": {"inherit-style": {"none"}},
|
||||
}
|
||||
|
||||
// clearRetiredFlag makes a retired value indistinguishable from an absent
|
||||
// flag. Resetting Changed matters as much as the value: the batch path
|
||||
// expresses "as if omitted" by deleting the key, so Changed() reports false
|
||||
// there. Leaving cobra's Changed at true would make a flag whose logic reads
|
||||
// Changed() (rather than the value) behave differently standalone than inside
|
||||
// +batch-update — and TestBatchOp_BodyMatchesStandalone only catches such a
|
||||
// split once it reaches the request body.
|
||||
func clearRetiredFlag(cmd *cobra.Command, name string) {
|
||||
_ = cmd.Flags().Set(name, "")
|
||||
if f := cmd.Flags().Lookup(name); f != nil {
|
||||
f.Changed = false
|
||||
}
|
||||
}
|
||||
|
||||
// isRetiredEnumValue reports whether val is a retired spelling for this
|
||||
// command's flag, i.e. one that should be cleared rather than rejected.
|
||||
func isRetiredEnumValue(command, flag, val string) bool {
|
||||
byFlag, ok := retiredEnumValues[command]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, retired := range byFlag[flag] {
|
||||
if strings.EqualFold(retired, val) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// canonicalEnumValue returns the enum entry an off-vocabulary value
|
||||
@@ -225,6 +441,10 @@ func chainEnumNormalization(cmd *cobra.Command) {
|
||||
c.Flags().Set(df.Name, canon)
|
||||
continue
|
||||
}
|
||||
if isRetiredEnumValue(cmd.Name(), df.Name, val) {
|
||||
clearRetiredFlag(c, df.Name)
|
||||
continue
|
||||
}
|
||||
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
|
||||
val, df.Name, strings.Join(df.Enum, ", ")).
|
||||
WithParam("--" + df.Name)
|
||||
|
||||
@@ -96,6 +96,54 @@ func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
|
||||
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
|
||||
// the per-op locator contract instead of offering a misleading fuzzy guess.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
|
||||
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
|
||||
}
|
||||
if strings.Contains(verr.Hint, "did you mean") {
|
||||
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
|
||||
}
|
||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
|
||||
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
|
||||
}
|
||||
if len(verr.Params[0].Suggestions) != 0 {
|
||||
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
|
||||
// special case is scoped to the two sheet-locator flags: any other unknown
|
||||
// flag on +batch-update keeps the normal did-you-mean behaviour.
|
||||
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+batch-update"}
|
||||
c.Flags().String("operations", "", "")
|
||||
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if strings.Contains(verr.Message, "no top-level sheet locator") {
|
||||
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
@@ -284,9 +332,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--col-size", "A:D",
|
||||
})
|
||||
ve := requireValidation(t, err, `unknown flag "--cols"`)
|
||||
ve := requireValidation(t, err, `unknown flag "--col-size"`)
|
||||
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
@@ -294,3 +342,289 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
|
||||
// habitual name with identical value semantics parses as the real flag on a
|
||||
// mounted command, costing zero round trips (eval: --cols, --file, --name,
|
||||
// --source/--target each burned an unknown-flag failure plus a --help call).
|
||||
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--cols", "A:D",
|
||||
"--width", "100",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "A:D") {
|
||||
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+sheet-create")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--name", "汇总",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--name should alias to --title and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "汇总") {
|
||||
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet-rename --new-name parses as --title", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+sheet-rename")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--new-name", "授权需求清单",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--new-name should alias to --title and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "授权需求清单") {
|
||||
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+range-fill")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--source", "B2",
|
||||
"--target", "B3:B10",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{"B2", "B3:B10"} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+csv-put")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--start-cell", "A1",
|
||||
"--file", "a,b\n1,2",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "a,b") {
|
||||
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cols-resize")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A:C",
|
||||
"--size", "120",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "120") {
|
||||
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+rows-resize")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "1:3",
|
||||
"--size", "36",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias never shadows a registered flag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &cobra.Command{Use: "+csv-put"}
|
||||
c.Flags().String("csv", "", "")
|
||||
c.Flags().String("file", "", "") // hypothetical real flag wins
|
||||
chainFlagAliases(c)
|
||||
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("file"); got != "x" {
|
||||
t.Errorf("registered --file should keep its own value, got %q", got)
|
||||
}
|
||||
if got, _ := c.Flags().GetString("csv"); got != "" {
|
||||
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
|
||||
// names whose fix is not a rename answer with the exact correct form, so the
|
||||
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
|
||||
// --help calls, +dim-insert kept failing even after reading help).
|
||||
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
command string
|
||||
args []string
|
||||
wrong string
|
||||
wantHint []string
|
||||
// rejectHint pins what a prescription must NOT name — used where the
|
||||
// obvious wording would steer into a deprecated flag.
|
||||
rejectHint []string
|
||||
}{
|
||||
{
|
||||
command: "+dim-insert",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
|
||||
wrong: "--dimension",
|
||||
wantHint: []string{"--position", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
|
||||
wrong: "--frozen-rows",
|
||||
// Must prescribe the CURRENT spelling: --dimension/--count is
|
||||
// retired and hidden from --help, so a hint naming it would point at
|
||||
// a flag missing from the same error's valid-flags list.
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
|
||||
wrong: "--bold",
|
||||
wantHint: []string{"--font-weight bold"},
|
||||
},
|
||||
{
|
||||
command: "+sheet-copy",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
|
||||
wrong: "--new-sheet-name",
|
||||
wantHint: []string{"--title", "source sheet"},
|
||||
},
|
||||
{
|
||||
command: "+table-put",
|
||||
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
|
||||
wrong: "--start-cell",
|
||||
wantHint: []string{`"start_cell"`, "+csv-put"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--values", `[["x"]]`},
|
||||
wrong: "--values",
|
||||
wantHint: []string{"--cells", "+workbook-create"},
|
||||
},
|
||||
{
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-row-count", "1"},
|
||||
wrong: "--frozen-row-count",
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
// The parse error reports the flag as typed: the underscore
|
||||
// spelling must hit the same curated entry as the hyphenated one.
|
||||
command: "+dim-freeze",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen_rows", "2"},
|
||||
wrong: "--frozen_rows",
|
||||
wantHint: []string{"--rows N"},
|
||||
rejectHint: []string{"--dimension", "--count"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--font-bold", "true"},
|
||||
wrong: "--font-bold",
|
||||
wantHint: []string{"--font-weight bold"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bg-color", "#FFF"},
|
||||
wrong: "--bg-color",
|
||||
wantHint: []string{"--background-color"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--wrap-strategy", "overflow"},
|
||||
wrong: "--wrap-strategy",
|
||||
wantHint: []string{"--word-wrap"},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-all", "thin"},
|
||||
wrong: "--border-all",
|
||||
wantHint: []string{"--border-styles", `"all"`},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-top", "thin"},
|
||||
wrong: "--border-top",
|
||||
wantHint: []string{"--border-styles", `"top"`},
|
||||
},
|
||||
{
|
||||
command: "+cells-set-style",
|
||||
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-color", "#000"},
|
||||
wrong: "--border-color",
|
||||
wantHint: []string{"--border-styles", "color"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, tc.command)
|
||||
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
|
||||
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
|
||||
for _, want := range tc.wantHint {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
// The valid-flags list is appended to the same Hint, so only the
|
||||
// prescription itself is checked for banned wording.
|
||||
prescription, _, _ := strings.Cut(ve.Hint, "; valid flags:")
|
||||
for _, banned := range tc.rejectHint {
|
||||
if strings.Contains(prescription, banned) {
|
||||
t.Errorf("prescription must not steer to %q, got %q", banned, prescription)
|
||||
}
|
||||
}
|
||||
// A curated prescription must not ship contradicting edit-distance
|
||||
// candidates (--font-bold used to carry --font-color/--font-line/
|
||||
// --font-size in params while the fix is --font-weight).
|
||||
for _, p := range ve.Params {
|
||||
if len(p.Suggestions) > 0 {
|
||||
t.Errorf("curated prescription should drop edit-distance suggestions, got %v", p.Suggestions)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
|
||||
// listing of introspectable flags; otherwise it returns the schema
|
||||
// subtree JSON for the named flag, or an error if the flag is not
|
||||
// registered.
|
||||
//
|
||||
// flagName also accepts a dotted path (properties.plotArea.axes): the
|
||||
// first segment names the flag, the rest walk the schema's properties
|
||||
// (descending through array items implicitly), returning just that
|
||||
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
|
||||
// lines — otherwise force agents to page through the full dump for one
|
||||
// nested field; eval traces show 25 such round trips in one batch.
|
||||
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return func(flagName string) ([]byte, error) {
|
||||
idx, err := loadFlagSchemas()
|
||||
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return json.MarshalIndent(map[string]interface{}{
|
||||
"shortcut": command,
|
||||
"introspectable_flags": flags,
|
||||
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
|
||||
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
|
||||
}, "", " ")
|
||||
}
|
||||
schema, ok := entry[flagName]
|
||||
name, path := splitSchemaPath(flagName)
|
||||
schema, ok := entry[name]
|
||||
if !ok {
|
||||
// Tolerate the wire-vocabulary underscore form (--flag-name
|
||||
// border_styles for border-styles) — agents copy field names out
|
||||
// of JSON payloads where underscores are canonical.
|
||||
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
||||
schema, ok = entry[alt]
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
@@ -114,14 +131,121 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
if err := json.Unmarshal(schema, &pretty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(path) > 0 {
|
||||
pretty, err = sliceSchemaByPath(pretty, name, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
return json.MarshalIndent(pretty, "", " ")
|
||||
}
|
||||
}
|
||||
|
||||
// splitSchemaPath splits a --flag-name value into the flag name and the
|
||||
// optional dotted schema path after it.
|
||||
func splitSchemaPath(flagName string) (string, []string) {
|
||||
parts := strings.Split(flagName, ".")
|
||||
return parts[0], parts[1:]
|
||||
}
|
||||
|
||||
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
|
||||
// Each segment matches a key under "properties"; array levels are descended
|
||||
// implicitly through "items" (an explicit "items" segment also works), and
|
||||
// oneOf branches are searched for the first one carrying the key. A miss
|
||||
// errors with the keys actually available at that level so the caller can
|
||||
// re-issue the path without a full dump.
|
||||
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
|
||||
node := schema
|
||||
walked := flagName
|
||||
for _, seg := range path {
|
||||
next, ok := schemaChild(node, seg)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
node = next
|
||||
walked += "." + seg
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// schemaChild resolves one path segment against a schema node, descending
|
||||
// through items / oneOf wrappers as needed.
|
||||
func schemaChild(node interface{}, seg string) (interface{}, bool) {
|
||||
for depth := 0; depth < 8; depth++ {
|
||||
m, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if seg == "items" {
|
||||
if items, ok := m["items"]; ok {
|
||||
return items, true
|
||||
}
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
if child, ok := props[seg]; ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
node = items
|
||||
continue
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
if child, ok := schemaChild(b, seg); ok {
|
||||
return child, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// schemaChildKeys lists the property keys reachable at a schema node (through
|
||||
// items / oneOf wrappers), for the path-miss error.
|
||||
func schemaChildKeys(node interface{}) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var collect func(n interface{}, depth int)
|
||||
collect = func(n interface{}, depth int) {
|
||||
if depth > 8 {
|
||||
return
|
||||
}
|
||||
m, ok := n.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if props, ok := m["properties"].(map[string]interface{}); ok {
|
||||
for k := range props {
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
return
|
||||
}
|
||||
if items, ok := m["items"]; ok {
|
||||
collect(items, depth+1)
|
||||
return
|
||||
}
|
||||
if branches, ok := m["oneOf"].([]interface{}); ok {
|
||||
for _, b := range branches {
|
||||
collect(b, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(node, 0)
|
||||
keys := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -204,3 +204,109 @@ func keysOf(m map[string]interface{}) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestPrintSchema_DottedPathSlicing covers --flag-name's dotted-path form,
|
||||
// which had no tests at all: disabling the implicit items/oneOf descent, or the
|
||||
// explicit "items" segment, broke nothing.
|
||||
//
|
||||
// The feature exists so agents can pull one subtree out of chart-create's
|
||||
// ~1,750-line properties schema instead of paging the whole dump (SKILL.md
|
||||
// points at it by name). A silent regression pushes them straight back to full
|
||||
// dumps, which is invisible in any output-correctness test.
|
||||
func TestPrintSchema_DottedPathSlicing(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+chart-create")
|
||||
|
||||
decode := func(t *testing.T, raw []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var node map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
t.Fatalf("schema slice is not a JSON object: %v", err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
props := func(t *testing.T, node map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
p, ok := node["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("node has no properties: %v", node)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
t.Run("one segment walks into properties", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := print("properties.snapshot")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
if _, has := props(t, decode(t, raw))["plotArea"]; !has {
|
||||
t.Errorf("snapshot subtree should expose plotArea, got %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array levels are descended implicitly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// data.refs is an array; naming the field must land on the ITEM shape,
|
||||
// not force the caller to spell ".items".
|
||||
raw, err := print("properties.snapshot.data.refs")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
node := decode(t, raw)
|
||||
if node["type"] != "array" {
|
||||
t.Errorf("refs should still be the array node, got %v", node["type"])
|
||||
}
|
||||
deeper, err := print("properties.snapshot.data.refs.value")
|
||||
if err != nil {
|
||||
t.Fatalf("descending through array items failed: %v", err)
|
||||
}
|
||||
if len(deeper) == 0 {
|
||||
t.Error("expected the item's value field")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an explicit items segment also works", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := print("properties.snapshot.plotArea.axes.items"); err != nil {
|
||||
t.Fatalf("explicit items segment failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a slice is strictly smaller than the whole flag schema", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
full, err := print("properties")
|
||||
if err != nil {
|
||||
t.Fatalf("full dump failed: %v", err)
|
||||
}
|
||||
slice, err := print("properties.snapshot.plotArea.axes")
|
||||
if err != nil {
|
||||
t.Fatalf("slice failed: %v", err)
|
||||
}
|
||||
if len(slice) >= len(full) {
|
||||
t.Errorf("slice is %d bytes vs %d for the full schema — slicing saves nothing", len(slice), len(full))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a miss names the keys actually available", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := print("properties.snapshot.nope")
|
||||
if err == nil {
|
||||
t.Fatal("want an error for an unknown segment")
|
||||
}
|
||||
ve := requireValidation(t, err, `no "nope" under properties.snapshot`)
|
||||
if !strings.Contains(ve.Message, "plotArea") {
|
||||
t.Errorf("the miss must list the reachable keys so the caller can retry without a full dump, got %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--flag-name" {
|
||||
t.Errorf("param = %q, want --flag-name", ve.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the underscore spelling of the flag still resolves", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := printFlagSchemaFor("+cells-set-style")("border_styles"); err != nil {
|
||||
t.Fatalf("underscore flag name should resolve to border-styles: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// ─── schema-driven flag validation ────────────────────────────────────
|
||||
@@ -94,7 +96,15 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
}
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, &schema, "", c)
|
||||
if len(c.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
vErr := c.errs[0]
|
||||
if len(c.errs) == 1 {
|
||||
// Single failure keeps the historical message byte-for-byte.
|
||||
//
|
||||
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
|
||||
// --properties) are the highest-frequency usage-layer failure for
|
||||
// sheets, and agents often burn several retries guessing the shape.
|
||||
@@ -106,19 +116,69 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
// exact JSON Schema for this (command, flag) pair; reaching this
|
||||
// branch means entry[name] resolved a schema from the embedded
|
||||
// index, so the suggested command is guaranteed to print it.
|
||||
// An enum-bearing field states its own contract far better than a
|
||||
// whole-payload skeleton, at any depth: --border-styles with
|
||||
// weight:1 used to answer with {"bottom": {…}, "left": {…}, …},
|
||||
// which says nothing about thin/medium/thick. Let those fall
|
||||
// through to the hintSuffix path below, which names the enum.
|
||||
var tm *typeMismatchError
|
||||
if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
isTypeMismatch := errors.As(vErr, &tm)
|
||||
if isTypeMismatch && len(tm.enum) == 0 && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
||||
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
|
||||
name, vErr.Error(), sk, command, name).WithCause(vErr)
|
||||
}
|
||||
}
|
||||
// Deep type mismatches don't get a whole-shape skeleton (it wouldn't
|
||||
// address the actual field), but if the field itself carries an enum /
|
||||
// description, append that one line — same "fix on first retry" goal.
|
||||
msg := vErr.Error()
|
||||
if isTypeMismatch {
|
||||
if suffix := tm.hintSuffix(); suffix != "" {
|
||||
msg += "; " + suffix
|
||||
}
|
||||
}
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, vErr.Error(), command, name).WithCause(vErr)
|
||||
name, msg, command, name).WithCause(vErr)
|
||||
}
|
||||
return nil
|
||||
// Multiple failures: report them all at once (numbered, each with its
|
||||
// own inline teaching hint) so the agent fixes the whole payload in one
|
||||
// retry instead of the fail-fast "fix one, hit the next" loop.
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, formatSchemaErrorList(c.errs), command, name).WithCause(vErr)
|
||||
}
|
||||
|
||||
// formatSchemaErrorList renders collected failures as a numbered one-line
|
||||
// list: "N validation errors: 1) …; 2) …". Type-mismatch entries carry
|
||||
// their enum/description suffix just like the single-error path. Entries
|
||||
// beyond schemaErrorDisplayLimit collapse into a "(more …)" tail — the
|
||||
// collector stops at cap, so the exact total is unknown by design.
|
||||
func formatSchemaErrorList(errs []error) string {
|
||||
shown := errs
|
||||
truncated := false
|
||||
if len(shown) > schemaErrorDisplayLimit {
|
||||
shown = shown[:schemaErrorDisplayLimit]
|
||||
truncated = true
|
||||
}
|
||||
parts := make([]string, 0, len(shown))
|
||||
for i, e := range shown {
|
||||
msg := e.Error()
|
||||
var tm *typeMismatchError
|
||||
if errors.As(e, &tm) {
|
||||
if suffix := tm.hintSuffix(); suffix != "" {
|
||||
msg += "; " + suffix
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d) %s", i+1, msg))
|
||||
}
|
||||
out := fmt.Sprintf("%d validation errors: %s", len(shown), strings.Join(parts, "; "))
|
||||
if truncated {
|
||||
out = fmt.Sprintf("%d+ validation errors: %s; (more errors not shown — fix these first)", schemaErrorDisplayLimit, strings.Join(parts, "; "))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateInputAgainstSchema validates input[flag] for every flag the
|
||||
@@ -187,8 +247,10 @@ var inputSchemaSkip = map[string]struct{}{
|
||||
}
|
||||
|
||||
// schemaProperty mirrors the JSON Schema subset used by
|
||||
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
|
||||
// they're documentation.
|
||||
// data/flag-schemas.json. Description is retained (not just documentation)
|
||||
// so a required-missing or type-mismatch error can inline the one-line
|
||||
// field doc — the agent then fixes the input without a --print-schema round
|
||||
// trip. Other unknown keys stay dropped.
|
||||
//
|
||||
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
|
||||
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
|
||||
@@ -204,6 +266,7 @@ var inputSchemaSkip = map[string]struct{}{
|
||||
// map<string, array<string>> fields (groups / collapse).
|
||||
type schemaProperty struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Nullable bool `json:"nullable"`
|
||||
Enum []interface{} `json:"enum"`
|
||||
Properties map[string]*schemaProperty `json:"properties"`
|
||||
@@ -242,20 +305,66 @@ func (a *additionalProps) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemaErrorCollector accumulates validation failures during one full
|
||||
// traversal so the caller can report every problem in a single reply
|
||||
// instead of the fail-fast "fix one, retry, hit the next" loop. Capacity
|
||||
// is bounded (collectSchemaErrorsCap) so a pathological payload — e.g. a
|
||||
// 5000-row --cells array where every cell is malformed — cannot balloon
|
||||
// the error message or the traversal cost: once full, collection
|
||||
// short-circuits everywhere via full().
|
||||
type schemaErrorCollector struct {
|
||||
errs []error
|
||||
}
|
||||
|
||||
// collectSchemaErrorsCap bounds how many errors one traversal gathers:
|
||||
// schemaErrorDisplayLimit entries are rendered; one extra is collected
|
||||
// only to know that truncation happened.
|
||||
const (
|
||||
schemaErrorDisplayLimit = 5
|
||||
collectSchemaErrorsCap = schemaErrorDisplayLimit + 1
|
||||
)
|
||||
|
||||
func (c *schemaErrorCollector) add(err error) {
|
||||
if len(c.errs) < collectSchemaErrorsCap {
|
||||
c.errs = append(c.errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *schemaErrorCollector) full() bool { return len(c.errs) >= collectSchemaErrorsCap }
|
||||
|
||||
// validateAgainstSchema recursively checks `value` against `schema`,
|
||||
// prefixing any failure with the JSON path navigated so far.
|
||||
// prefixing any failure with the JSON path navigated so far. It reports
|
||||
// only the first failure — callers that want the full list (the
|
||||
// error-as-teaching aggregate path) use collectSchemaErrors directly.
|
||||
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
|
||||
if schema == nil {
|
||||
return nil // defensive — current callers always pass &schema, but
|
||||
// keeps validator safe for future programmatic construction.
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, schema, path, c)
|
||||
if len(c.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.errs[0]
|
||||
}
|
||||
|
||||
// collectSchemaErrors is the traversal engine behind validateAgainstSchema:
|
||||
// same checks, same messages, same deterministic order, but it keeps
|
||||
// walking after a failure and appends every problem to the collector
|
||||
// (until cap). Two deliberate exceptions to "keep walking":
|
||||
// - a type mismatch stops descent into that node (its children would
|
||||
// produce cascading nonsense against the wrong-typed value);
|
||||
// - oneOf alternatives are probed with throwaway collectors (a failed
|
||||
// alternative is not an error when a later one matches).
|
||||
func collectSchemaErrors(value interface{}, schema *schemaProperty, path string, c *schemaErrorCollector) {
|
||||
if schema == nil || c.full() {
|
||||
return
|
||||
}
|
||||
if value == nil && schema.Nullable {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
|
||||
c.add(&typeMismatchError{path: path, expected: schema.Type, got: jsType(value), enum: schema.Enum, description: schema.Description})
|
||||
return // wrong container type — descending would cascade nonsense.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,20 +372,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// already reported above). Apply to both `number` and `integer` types.
|
||||
if num, ok := value.(float64); ok {
|
||||
if schema.Minimum != nil && num < *schema.Minimum {
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
if schema.Maximum != nil && num > *schema.Maximum {
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
// Array length bounds — only checked when value is an array.
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,20 +403,22 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
||||
}
|
||||
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
if len(schema.OneOf) > 0 {
|
||||
matched := false
|
||||
for _, sub := range schema.OneOf {
|
||||
if validateAgainstSchema(value, sub, path) == nil {
|
||||
probe := &schemaErrorCollector{}
|
||||
collectSchemaErrors(value, sub, path, probe)
|
||||
if len(probe.errs) == 0 {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
c.add(fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,8 +427,18 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
// the schema also describes their per-key shape via `properties`.
|
||||
if obj, ok := value.(map[string]interface{}); ok {
|
||||
for _, key := range schema.Required {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
if _, present := obj[key]; !present {
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
msg := fmt.Sprintf("required property %q is missing at %s", key, pathOrRoot(path))
|
||||
// Inline the missing field's type / one-line description / enum so
|
||||
// the agent supplies a correctly-shaped value on the first retry
|
||||
// instead of fetching the full schema.
|
||||
if hint := schemaFieldHint(schema.Properties[key]); hint != "" {
|
||||
msg += "; expected " + hint
|
||||
}
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
}
|
||||
}
|
||||
if schema.Properties != nil {
|
||||
@@ -327,6 +448,9 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
sub := schema.Properties[key]
|
||||
v, present := obj[key]
|
||||
if !present {
|
||||
@@ -350,14 +474,12 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(v, sub, child); err != nil {
|
||||
return err
|
||||
}
|
||||
collectSchemaErrors(v, sub, child, c)
|
||||
}
|
||||
}
|
||||
// additionalProperties: enforce only when explicitly declared.
|
||||
// Absent means lenient (matches the file header's stance). Sort
|
||||
// extras so the first rejection is deterministic across runs.
|
||||
// extras so rejection order is deterministic across runs.
|
||||
if schema.AdditionalProperties != nil {
|
||||
extras := make([]string, 0)
|
||||
for key := range obj {
|
||||
@@ -368,17 +490,29 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
}
|
||||
sort.Strings(extras)
|
||||
for _, key := range extras {
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
if schema.AdditionalProperties.Strict {
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
msg := fmt.Sprintf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
||||
// Inline the node's declared keys (and a did-you-mean when the
|
||||
// unknown key is a near miss) so the agent renames it in one
|
||||
// retry instead of a --print-schema round trip.
|
||||
if legal := sortedSchemaPropertyKeys(schema.Properties); len(legal) > 0 {
|
||||
if guess := suggest.Closest(key, legal, 1); len(guess) > 0 {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, guess[0])
|
||||
}
|
||||
msg += "; valid properties: " + formatPropertyKeyList(legal)
|
||||
}
|
||||
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
||||
continue
|
||||
}
|
||||
if schema.AdditionalProperties.Schema != nil {
|
||||
child := key
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
|
||||
return err
|
||||
}
|
||||
collectSchemaErrors(obj[key], schema.AdditionalProperties.Schema, child, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,33 +521,50 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
|
||||
if schema.Type == "array" && schema.Items != nil {
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil // type mismatch already reported above.
|
||||
return // type mismatch already reported above.
|
||||
}
|
||||
for i, item := range arr {
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
|
||||
return err
|
||||
if c.full() {
|
||||
return
|
||||
}
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
collectSchemaErrors(item, schema.Items, child, c)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// typeMismatchError is the type-check branch of validateAgainstSchema
|
||||
// as a typed error, so validateValueAgainstSchema can recognize shape
|
||||
// confusion (vs. deep value errors) and inline a skeleton of the
|
||||
// expected shape. Error() keeps the exact legacy wording.
|
||||
// expected shape. Error() keeps the exact legacy wording; enum /
|
||||
// description ride alongside for the deep-mismatch hintSuffix, so they
|
||||
// never leak into the shallow-skeleton message.
|
||||
type typeMismatchError struct {
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
path string
|
||||
expected string
|
||||
got string
|
||||
enum []interface{}
|
||||
description string
|
||||
}
|
||||
|
||||
func (e *typeMismatchError) Error() string {
|
||||
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
|
||||
}
|
||||
|
||||
// hintSuffix renders the field's description / enum as a one-line tail for
|
||||
// the deep type-mismatch fallback (type is already stated by Error()).
|
||||
// Empty when the field declares neither.
|
||||
func (e *typeMismatchError) hintSuffix() string {
|
||||
var parts []string
|
||||
if d := oneLineDescription(e.description); d != "" {
|
||||
parts = append(parts, "description: "+d)
|
||||
}
|
||||
if len(e.enum) > 0 {
|
||||
parts = append(parts, "one of "+formatEnum(e.enum))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// pathDepth counts how many levels below the flag root a JSON path
|
||||
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
|
||||
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
|
||||
@@ -605,6 +756,70 @@ func joinFormatted(values []interface{}) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// schemaFieldHint renders a compact one-line "type X, description: …, one of
|
||||
// […]" sketch of a single field's schema, used to enrich a required-missing
|
||||
// error so the agent supplies a correctly-shaped value without --print-schema.
|
||||
// Empty when the field declares none of type / description / enum.
|
||||
func schemaFieldHint(s *schemaProperty) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if s.Type != "" {
|
||||
parts = append(parts, fmt.Sprintf("type %q", s.Type))
|
||||
}
|
||||
if d := oneLineDescription(s.Description); d != "" {
|
||||
parts = append(parts, "description: "+d)
|
||||
}
|
||||
if len(s.Enum) > 0 {
|
||||
parts = append(parts, "one of "+formatEnum(s.Enum))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// sortedSchemaPropertyKeys returns the declared property names in a stable
|
||||
// (sorted) order so the valid-property list in a strict unexpected-property
|
||||
// error is deterministic across runs.
|
||||
func sortedSchemaPropertyKeys(props map[string]*schemaProperty) []string {
|
||||
keys := make([]string, 0, len(props))
|
||||
for k := range props {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// propertyKeyDisplayLimit caps how many declared property names ride inline on
|
||||
// a strict unexpected-property error, so a wide object doesn't bury the actual
|
||||
// error under a wall of keys. Overflow is summarised as "(N more)".
|
||||
const propertyKeyDisplayLimit = 15
|
||||
|
||||
func formatPropertyKeyList(keys []string) string {
|
||||
if len(keys) <= propertyKeyDisplayLimit {
|
||||
return "[" + strings.Join(keys, ", ") + "]"
|
||||
}
|
||||
shown := keys[:propertyKeyDisplayLimit]
|
||||
return fmt.Sprintf("[%s, … (%d more)]", strings.Join(shown, ", "), len(keys)-propertyKeyDisplayLimit)
|
||||
}
|
||||
|
||||
// descriptionMaxLen bounds an inlined field description to one reasonable line;
|
||||
// schema descriptions can run several sentences, which would swamp the error.
|
||||
const descriptionMaxLen = 120
|
||||
|
||||
// oneLineDescription collapses a (possibly multi-line) schema description into
|
||||
// a single whitespace-normalised line, truncated to descriptionMaxLen runes.
|
||||
// Returns "" for an empty / whitespace-only description.
|
||||
func oneLineDescription(s string) string {
|
||||
collapsed := strings.Join(strings.Fields(s), " ")
|
||||
if collapsed == "" {
|
||||
return ""
|
||||
}
|
||||
if r := []rune(collapsed); len(r) > descriptionMaxLen {
|
||||
return string(r[:descriptionMaxLen]) + "…"
|
||||
}
|
||||
return collapsed
|
||||
}
|
||||
|
||||
// suggestEnumMatch returns the canonical enum entry when the user's
|
||||
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs
|
||||
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical
|
||||
|
||||
@@ -5,6 +5,8 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -438,6 +440,372 @@ func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys pins the strict
|
||||
// additionalProperties:false enhancement: the error lists the node's legal
|
||||
// property keys (sorted, capped at 15 with an "(N more)" overflow) and, when
|
||||
// the unknown key is a near miss, appends a did-you-mean.
|
||||
func TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("lists legal keys and suggests a near miss", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{
|
||||
"background_color":{"type":"string"},
|
||||
"font_weight":{"type":"string"},
|
||||
"font_size":{"type":"integer"}
|
||||
}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"background_colour": "#fff"}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key under strict schema must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, `unexpected property "background_colour"`) {
|
||||
t.Errorf("want the offending key named; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, `did you mean "background_color"?`) {
|
||||
t.Errorf("want a did-you-mean for the near miss; got %q", msg)
|
||||
}
|
||||
for _, want := range []string{"valid properties:", "background_color", "font_size", "font_weight"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want valid-property list to contain %q; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no did-you-mean for an unrelated key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{"background_color":{"type":"string"}}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"zzzzzzzz": 1}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "did you mean") {
|
||||
t.Errorf("unrelated key should get no suggestion; got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "valid properties: [background_color]") {
|
||||
t.Errorf("want the valid-property list; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wide object truncates the key list with overflow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
props := make([]string, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
props = append(props, fmt.Sprintf(`"k%02d":{"type":"string"}`, i))
|
||||
}
|
||||
schema := parseSchema(t, `{"type":"object","additionalProperties":false,"properties":{`+strings.Join(props, ",")+`}}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"nope": 1}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("unknown key must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "(5 more)") { // 20 keys, cap 15
|
||||
t.Errorf("want overflow marker '(5 more)'; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_RequiredMissingInlinesFieldHint pins that a
|
||||
// required-property-missing error inlines the field's type / one-line
|
||||
// description / enum when the schema describes that field.
|
||||
func TestValidateAgainstSchema_RequiredMissingInlinesFieldHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"required":["operation"],
|
||||
"properties":{
|
||||
"operation":{
|
||||
"type":"string",
|
||||
"description":"Which mutation to run.",
|
||||
"enum":["insert","delete","move"]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("missing required property must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`required property "operation"`,
|
||||
`type "string"`,
|
||||
"description: Which mutation to run.",
|
||||
`one of ["insert", "delete", "move"]`,
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in required-missing error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain pins that a
|
||||
// missing required key with no describing schema keeps the plain legacy
|
||||
// message (no trailing "expected ...").
|
||||
func TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{"type":"object","required":["a"]}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("missing required must fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), "; expected") {
|
||||
t.Errorf("no field schema → no inlined hint; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum pins that a deep
|
||||
// type mismatch (past the skeleton depth limit) still gets no whole-shape
|
||||
// skeleton, but appends the field's enum / description one-liner.
|
||||
func TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A wrong-typed value three levels deep where the field is an enum string.
|
||||
schema := parseSchema(t, `{
|
||||
"type":"array",
|
||||
"items":{"type":"array","items":{"type":"object","properties":{
|
||||
"align":{"type":"string","description":"Text alignment.","enum":["left","center","right"]}
|
||||
}}}
|
||||
}`)
|
||||
deep := parseValue(t, `[[{"align":42}]]`)
|
||||
err := validateAgainstSchema(deep, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("wrong type for align must fail")
|
||||
}
|
||||
var tm *typeMismatchError
|
||||
if !errors.As(err, &tm) {
|
||||
t.Fatalf("want *typeMismatchError, got %T", err)
|
||||
}
|
||||
suffix := tm.hintSuffix()
|
||||
for _, want := range []string{"description: Text alignment.", `one of ["left", "center", "right"]`} {
|
||||
if !strings.Contains(suffix, want) {
|
||||
t.Errorf("want %q in hintSuffix; got %q", want, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaFieldHint covers the single-field sketch used by
|
||||
// required-missing errors: each of type / description / enum contributes
|
||||
// its own segment, absent parts are simply skipped, and a nil / empty
|
||||
// schema yields no hint at all.
|
||||
func TestSchemaFieldHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
schema *schemaProperty
|
||||
want string
|
||||
}{
|
||||
{"nil schema", nil, ""},
|
||||
{"empty schema", &schemaProperty{}, ""},
|
||||
{"type only", &schemaProperty{Type: "string"}, `type "string"`},
|
||||
{"description only", &schemaProperty{Description: "Cell note."}, "description: Cell note."},
|
||||
{"enum only", &schemaProperty{Enum: []interface{}{"a", "b"}}, `one of ["a", "b"]`},
|
||||
{
|
||||
"all three",
|
||||
&schemaProperty{Type: "string", Description: "段类型", Enum: []interface{}{"text", "link"}},
|
||||
`type "string", description: 段类型, one of ["text", "link"]`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := schemaFieldHint(tc.schema); got != tc.want {
|
||||
t.Errorf("schemaFieldHint = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatPropertyKeyList_Boundaries pins the display cap edges: exactly
|
||||
// at the cap nothing is folded, one past the cap folds into "(1 more)".
|
||||
func TestFormatPropertyKeyList_Boundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := make([]string, 0, propertyKeyDisplayLimit+1)
|
||||
for i := 0; i < propertyKeyDisplayLimit; i++ {
|
||||
keys = append(keys, fmt.Sprintf("k%02d", i))
|
||||
}
|
||||
if got := formatPropertyKeyList(keys); strings.Contains(got, "more)") {
|
||||
t.Errorf("exactly %d keys must not fold, got %q", propertyKeyDisplayLimit, got)
|
||||
}
|
||||
keys = append(keys, "overflow")
|
||||
if got := formatPropertyKeyList(keys); !strings.Contains(got, "(1 more)") {
|
||||
t.Errorf("%d keys should fold into '(1 more)', got %q", propertyKeyDisplayLimit+1, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypeMismatchHintSuffix_EmptyWhenUndeclared pins that a field with
|
||||
// neither enum nor description adds no suffix — the deep-mismatch fallback
|
||||
// message must stay byte-identical to the legacy wording in that case.
|
||||
func TestTypeMismatchHintSuffix_EmptyWhenUndeclared(t *testing.T) {
|
||||
t.Parallel()
|
||||
tm := &typeMismatchError{path: "a.b", expected: "string", got: "number"}
|
||||
if got := tm.hintSuffix(); got != "" {
|
||||
t.Errorf("no enum/description → empty suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo pins the
|
||||
// did-you-mean for a key that differs from a legal one only in casing /
|
||||
// underscore style — a high-frequency LLM slip.
|
||||
func TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"additionalProperties":false,
|
||||
"properties":{"background_color":{"type":"string"}}
|
||||
}`)
|
||||
err := validateAgainstSchema(map[string]interface{}{"Background_Color": "#fff"}, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("case-typo key under strict schema must fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `did you mean "background_color"?`) {
|
||||
t.Errorf("want case-insensitive did-you-mean; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_RequiredMissingRealSchema replays 场景3
|
||||
// of the doubao case against the real embedded flag-schemas.json: a
|
||||
// rich_text segment without "type" must inline the field's type, enum and
|
||||
// description while keeping the --print-schema pointer.
|
||||
func TestValidateValueAgainstSchema_RequiredMissingRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("rich_text without type must fail against the embedded schema")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`required property "type" is missing`,
|
||||
`expected type "string"`,
|
||||
"one of [",
|
||||
`"text"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in real-schema required-missing error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_DeepMismatchRealSchema replays 场景4: a
|
||||
// numeric rich_text "type" three levels deep gets the field's enum inline
|
||||
// (no whole-shape skeleton), still with the --print-schema pointer.
|
||||
func TestValidateValueAgainstSchema_DeepMismatchRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
value := parseValue(t, `[[{"rich_text":[{"type":42,"text":"x"}]}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("numeric rich_text type must fail against the embedded schema")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
`expected type "string", got "number"`,
|
||||
"one of [",
|
||||
`"text"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in real-schema deep-mismatch error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
if strings.Contains(msg, "expected shape:") {
|
||||
t.Errorf("deep mismatch must not inline a skeleton; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_AggregatesMultipleErrors pins the
|
||||
// aggregate path: a payload with several independent problems reports them
|
||||
// all in one numbered reply (each with its own teaching hint) instead of
|
||||
// the fail-fast fix-one-retry-hit-the-next loop.
|
||||
func TestValidateValueAgainstSchema_AggregatesMultipleErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
// Two independent problems in one --cells payload: cell[0][0].rich_text[0]
|
||||
// misses required "type"; cell[0][1].note has the wrong type.
|
||||
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]},{"note":12.5}]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("payload with two problems must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{
|
||||
"2 validation errors:",
|
||||
`1) required property "type" is missing`,
|
||||
`one of ["text"`, // teaching hint rides along in aggregate mode too
|
||||
`2) [0][1].note: expected type "string"`,
|
||||
"--print-schema",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("want %q in aggregated error; got %q", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_AggregateCapTruncates pins the display
|
||||
// cap: a pathological payload reports schemaErrorDisplayLimit entries and
|
||||
// an explicit truncation tail, never the full flood.
|
||||
func TestValidateValueAgainstSchema_AggregateCapTruncates(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
// Seven cells all missing required rich_text "type" → 7 independent errors.
|
||||
row := make([]string, 0, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
row = append(row, `{"rich_text":[{"text":"x"}]}`)
|
||||
}
|
||||
value := parseValue(t, `[[`+strings.Join(row, ",")+`]]`)
|
||||
err := validateValueAgainstSchema(fv, "cells", value)
|
||||
if err == nil {
|
||||
t.Fatal("payload with seven problems must fail")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "5+ validation errors:") {
|
||||
t.Errorf("want capped header '5+ validation errors:'; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "more errors not shown") {
|
||||
t.Errorf("want truncation tail; got %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "6)") {
|
||||
t.Errorf("must not render entries beyond the display limit; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectSchemaErrors_OneOfProbeDoesNotLeak pins that failed oneOf
|
||||
// alternatives don't leak probe errors into the caller's collector when a
|
||||
// later alternative matches.
|
||||
func TestCollectSchemaErrors_OneOfProbeDoesNotLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{"oneOf":[{"type":"string"},{"type":"number"}]}`)
|
||||
c := &schemaErrorCollector{}
|
||||
collectSchemaErrors(42.0, schema, "", c)
|
||||
if len(c.errs) != 0 {
|
||||
t.Errorf("number matches the second oneOf alternative; want no errors, got %v", c.errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneLineDescription(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := oneLineDescription(" "); got != "" {
|
||||
t.Errorf("whitespace-only → empty, got %q", got)
|
||||
}
|
||||
if got := oneLineDescription("line one\n line two"); got != "line one line two" {
|
||||
t.Errorf("multi-line collapse = %q", got)
|
||||
}
|
||||
long := strings.Repeat("x", 200)
|
||||
got := oneLineDescription(long)
|
||||
if !strings.HasSuffix(got, "…") || len([]rune(got)) != descriptionMaxLen+1 {
|
||||
t.Errorf("long description should truncate to %d runes + ellipsis, got %d", descriptionMaxLen, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
|
||||
@@ -34,6 +34,7 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+rows-resize": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+styles-put": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -333,6 +333,12 @@ func (m *mapFlagView) normalizeAndValidateEnums() error {
|
||||
m.raw[rawKey] = canonical
|
||||
continue
|
||||
}
|
||||
// A retired value means "as if omitted" — delete the key so Changed()
|
||||
// also reports it as absent, matching the standalone path.
|
||||
if isRetiredEnumValue(m.command, df.Name, value) {
|
||||
delete(m.raw, rawKey)
|
||||
continue
|
||||
}
|
||||
message := fmt.Sprintf("invalid value %q for --%s, allowed: %s", value, df.Name, strings.Join(df.Enum, ", "))
|
||||
if match := closestEnumValue(value, df.Enum); match != "" {
|
||||
message += fmt.Sprintf("; did you mean %q?", match)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
|
||||
@@ -320,7 +319,12 @@ func requireSheetSelector(sheetID, sheetName string) error {
|
||||
sheetID = strings.TrimSpace(sheetID)
|
||||
sheetName = strings.TrimSpace(sheetName)
|
||||
if sheetID == "" && sheetName == "" {
|
||||
// Eval traces show every occurrence recovering on the next call, so
|
||||
// the gap is knowing WHICH name to pass, not that one is needed: a
|
||||
// just-created workbook has a single sheet named Sheet1, and any
|
||||
// other workbook needs one +workbook-info lookup.
|
||||
return common.ValidationErrorf("specify at least one of --sheet-id or --sheet-name").
|
||||
WithHint("a freshly created workbook has one sheet named Sheet1 (`--sheet-name Sheet1`); otherwise list the real sheets with `lark-cli sheets +workbook-info --url <URL>`").
|
||||
WithParams(
|
||||
sheetsInvalidParam("sheet-id", "required; specify at least one"),
|
||||
sheetsInvalidParam("sheet-name", "required; specify at least one"),
|
||||
@@ -425,6 +429,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
}
|
||||
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
|
||||
}
|
||||
// Unambiguous habitual shapes are rewritten onto the wire contract
|
||||
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
|
||||
// so both the standalone cobra path and +batch-update sub-ops (whose
|
||||
// mapFlagView.Str re-encodes composites through here) get the rewrite.
|
||||
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
|
||||
out = norm(out)
|
||||
}
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
// --properties (validated at the input-builder tail after enhance
|
||||
// hooks fill in flat-flag-derived fields) and any flag without an
|
||||
@@ -435,6 +446,134 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
|
||||
// input shapes onto the wire contract before schema validation — same
|
||||
// contract as enum normalization: only a shape whose meaning is beyond
|
||||
// doubt may be rewritten; anything ambiguous must fail with a prescription
|
||||
// instead. Applied to the parsed JSON value inside parseJSONFlag.
|
||||
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
|
||||
"+cells-set": {"cells": normalizeCellsFlagValue},
|
||||
"+cells-set-style": {"border-styles": normalizeBorderStylesFlagValue},
|
||||
"+cells-batch-set-style": {"border-styles": normalizeBorderStylesFlagValue},
|
||||
"+chart-create": {"properties": normalizeChartHexColors},
|
||||
"+chart-update": {"properties": normalizeChartHexColors},
|
||||
}
|
||||
|
||||
// normalizeChartHexColors walks a chart properties payload and prefixes bare
|
||||
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
|
||||
// Excel-habit form the chart backend rejects with "expected rgba() or
|
||||
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
|
||||
// bare hex color is untouched.
|
||||
func normalizeChartHexColors(v interface{}) interface{} {
|
||||
switch t := v.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, val := range t {
|
||||
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
|
||||
t[k] = "#" + s
|
||||
continue
|
||||
}
|
||||
// A color key can hold an ARRAY of colors (colorTheme, series
|
||||
// palettes). Recursing without the key would lose the color
|
||||
// context and leave bare hex strings unprefixed, so the server
|
||||
// rejects a payload the schema itself allows.
|
||||
if arr, ok := val.([]interface{}); ok && isColorKey(k) {
|
||||
normalizeChartHexColorList(arr)
|
||||
continue
|
||||
}
|
||||
normalizeChartHexColors(val)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, e := range t {
|
||||
normalizeChartHexColors(e)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeChartHexColorList prefixes bare hex strings inside an array that
|
||||
// sits under a color key, and keeps descending for nested shapes.
|
||||
func normalizeChartHexColorList(arr []interface{}) {
|
||||
for i, e := range arr {
|
||||
if s, ok := e.(string); ok {
|
||||
if isBareHexColor(s) {
|
||||
arr[i] = "#" + s
|
||||
}
|
||||
continue
|
||||
}
|
||||
if nested, ok := e.([]interface{}); ok {
|
||||
normalizeChartHexColorList(nested)
|
||||
continue
|
||||
}
|
||||
normalizeChartHexColors(e)
|
||||
}
|
||||
}
|
||||
|
||||
// isColorKey reports whether a key names a color (or a list of colors). The
|
||||
// value gate is isBareHexColor — a strict 6/8-digit hex check — so matching a
|
||||
// key generously is safe: a non-hex value under a color-ish key is left alone.
|
||||
// Plural and color-prefixed forms matter because the chart schema uses
|
||||
// colorTheme / colorScale / colorGradient / highlight_colors, none of which
|
||||
// end in "color".
|
||||
func isColorKey(k string) bool {
|
||||
if k == "color" || k == "colors" {
|
||||
return true
|
||||
}
|
||||
for _, suffix := range []string{"_color", "Color", "_colors", "Colors"} {
|
||||
if strings.HasSuffix(k, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(k, "color") || strings.HasPrefix(k, "Color")
|
||||
}
|
||||
|
||||
func isBareHexColor(s string) bool {
|
||||
if len(s) != 6 && len(s) != 8 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cellObjectKeys pins the property vocabulary of a single cell in the
|
||||
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
|
||||
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
|
||||
var cellObjectKeys = map[string]struct{}{
|
||||
"border_styles": {},
|
||||
"cell_styles": {},
|
||||
"data_validation": {},
|
||||
"formula": {},
|
||||
"multiple_values": {},
|
||||
"note": {},
|
||||
"rich_text": {},
|
||||
"value": {},
|
||||
}
|
||||
|
||||
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
|
||||
// --cells contract expects. Eval traces show agents writing a single cell
|
||||
// routinely pass {"value":…} without the two array layers; when every key
|
||||
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
|
||||
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
|
||||
// row or a column) is returned untouched for the schema validator to
|
||||
// prescribe.
|
||||
func wrapLoneCellObject(v interface{}) interface{} {
|
||||
obj, ok := v.(map[string]interface{})
|
||||
if !ok || len(obj) == 0 {
|
||||
return v
|
||||
}
|
||||
for k := range obj {
|
||||
if _, known := cellObjectKeys[k]; !known {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return []interface{}{[]interface{}{obj}}
|
||||
}
|
||||
|
||||
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
|
||||
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, name)
|
||||
@@ -451,6 +590,51 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ─── aggregated sub-error rendering ────────────────────────────────────
|
||||
//
|
||||
// Several flags collect per-item failures and fold them into ONE typed error
|
||||
// (--styles, --writes, --operations). A Problem carries a single Hint slot,
|
||||
// so the naive fold — taking only each inner error's Message — silently drops
|
||||
// the very prescriptions this domain adds (requireSheetSelector's
|
||||
// "+workbook-info" pointer, the batch key contract). These two helpers keep
|
||||
// them: a lone failure hands its Hint to the outer error's Hint field, and a
|
||||
// folded list inlines each hint next to its own message.
|
||||
|
||||
// aggregatedIssueParts splits a collected sub-error into its message and its
|
||||
// hint ("" when it carries none), unwrapping the typed Problem so the message
|
||||
// is the bare text rather than the Error() rendering.
|
||||
func aggregatedIssueParts(err error) (msg, hint string) {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Message, p.Hint
|
||||
}
|
||||
return err.Error(), ""
|
||||
}
|
||||
|
||||
// aggregatedIssueText renders one collected sub-error for a folded, multi-issue
|
||||
// message, appending its hint in parentheses so a per-item prescription is not
|
||||
// lost to the single shared Hint slot.
|
||||
func aggregatedIssueText(err error) string {
|
||||
msg, hint := aggregatedIssueParts(err)
|
||||
if hint == "" {
|
||||
return msg
|
||||
}
|
||||
return msg + " (" + hint + ")"
|
||||
}
|
||||
|
||||
// prefixValidationIssue re-labels a collected sub-error with the path it was
|
||||
// found at ("--writes[2]"), keeping its Hint. Formatting the inner error into
|
||||
// a new message with "%v" would drop that hint on the floor — the collectors
|
||||
// only ever read Message and Hint, so the two must stay separate all the way
|
||||
// to the fold.
|
||||
func prefixValidationIssue(path string, err error) error {
|
||||
msg, hint := aggregatedIssueParts(err)
|
||||
out := common.ValidationErrorf("%s: %s", path, msg).WithCause(err)
|
||||
if hint != "" {
|
||||
out = out.WithHint("%s", hint)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// requireJSONArray is parseJSONFlag + a type assertion to []interface{}.
|
||||
func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, name)
|
||||
@@ -466,146 +650,3 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
style := map[string]interface{}{}
|
||||
if v := runtime.Str("background-color"); v != "" {
|
||||
style["background_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-family"); v != "" {
|
||||
style["font_family"] = v
|
||||
}
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
if v := runtime.Str("font-style"); v != "" {
|
||||
style["font_style"] = v
|
||||
}
|
||||
if v := runtime.Str("font-weight"); v != "" {
|
||||
style["font_weight"] = v
|
||||
}
|
||||
if v := runtime.Str("font-line"); v != "" {
|
||||
style["font_line"] = v
|
||||
}
|
||||
if v := runtime.Str("horizontal-alignment"); v != "" {
|
||||
style["horizontal_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("vertical-alignment"); v != "" {
|
||||
style["vertical_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("word-wrap"); v != "" {
|
||||
style["word_wrap"] = v
|
||||
}
|
||||
if v := runtime.Str("number-format"); v != "" {
|
||||
style["number_format"] = v
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// cellStyleAliases maps shorthand cell_styles field names that models commonly
|
||||
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
|
||||
// names the backend expects. Only the unambiguous alignment shorthands are
|
||||
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
|
||||
// "bg_color", "text_align") are intentionally left out so a wrong guess still
|
||||
// surfaces as an error rather than being silently reinterpreted.
|
||||
var cellStyleAliases = []struct{ alias, canonical string }{
|
||||
{"horizontal_align", "horizontal_alignment"},
|
||||
{"halign", "horizontal_alignment"},
|
||||
{"vertical_align", "vertical_alignment"},
|
||||
{"valign", "vertical_alignment"},
|
||||
}
|
||||
|
||||
// normalizeCellStyleAliases renames known shorthand keys in a single
|
||||
// cell_styles map to their canonical equivalents, in place, so a model that
|
||||
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
|
||||
// applies the style instead of hitting an "unsupported field" error (--styles)
|
||||
// or having the field silently dropped by the backend (typed --cells). If both
|
||||
// the shorthand and its canonical key are present it returns a validation error
|
||||
// rather than picking one. path labels the map for the error message.
|
||||
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
|
||||
if len(style) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, a := range cellStyleAliases {
|
||||
v, ok := style[a.alias]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := style[a.canonical]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
|
||||
}
|
||||
style[a.canonical] = v
|
||||
delete(style, a.alias)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
|
||||
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
|
||||
// alignment shorthands are accepted on +cells-set the same as on --styles.
|
||||
// Structure is checked leniently to match the pass-through contract: any
|
||||
// element that isn't the expected shape is skipped, not rejected.
|
||||
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for c, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects). Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
if runtime.Str("border-styles") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "border-styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// requireAnyStyleFlag ensures at least one style-defining flag (style or
|
||||
// border) is set — otherwise the request would do nothing.
|
||||
func requireAnyStyleFlag(runtime flagView) error {
|
||||
if len(buildCellStyleFromFlags(runtime)) > 0 {
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
|
||||
WithParams(
|
||||
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,6 +144,13 @@ func TestSheetHelpersValidationMetadata(t *testing.T) {
|
||||
if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" {
|
||||
t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params)
|
||||
}
|
||||
// Eval traces recover on the very next call, so the missing piece is
|
||||
// which name to pass — the hint has to name Sheet1 and the lookup.
|
||||
for _, want := range []string{"Sheet1", "+workbook-info"} {
|
||||
if !strings.Contains(validationErr.Hint, want) {
|
||||
t.Errorf("hint should mention %q, got %q", want, validationErr.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("spreadsheet url shape reports url param", func(t *testing.T) {
|
||||
@@ -224,6 +231,19 @@ func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface
|
||||
return calls
|
||||
}
|
||||
|
||||
// dryRunWarning returns the advisory text a dry-run surfaces under
|
||||
// data.warning_message, or "" when the shortcut emitted none.
|
||||
func dryRunWarning(t *testing.T, sc common.Shortcut, args []string) string {
|
||||
t.Helper()
|
||||
out, err := runShortcut(t, sc, append(args, "--dry-run"))
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
|
||||
}
|
||||
data, _ := decodeDryRunRaw(t, out)["data"].(map[string]interface{})
|
||||
warning, _ := data["warning_message"].(string)
|
||||
return warning
|
||||
}
|
||||
|
||||
func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} {
|
||||
t.Helper()
|
||||
idx := strings.Index(out, "{")
|
||||
|
||||
312
shortcuts/sheets/json_flag_normalize_test.go
Normal file
312
shortcuts/sheets/json_flag_normalize_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWrapLoneCellObject pins the auto-wrap contract: a bare cell object —
|
||||
// the classic missing-[[…]] shape agents produce for a 1×1 write — is
|
||||
// rewritten to [[cell]]; anything whose meaning is not beyond doubt stays
|
||||
// untouched for the schema validator to prescribe.
|
||||
func TestWrapLoneCellObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wrapped bool
|
||||
}{
|
||||
{"lone value cell", `{"value":"hi"}`, true},
|
||||
{"lone formula cell with styles", `{"formula":"=SUM(A1:A3)","cell_styles":{"font_weight":"bold"}}`, true},
|
||||
{"unknown key stays", `{"value":"hi","range":"A1"}`, false},
|
||||
{"array of cells stays (row vs column ambiguous)", `[{"value":"a"},{"value":"b"}]`, false},
|
||||
{"proper 2D array stays", `[[{"value":"a"}]]`, false},
|
||||
{"empty object stays", `{}`, false},
|
||||
{"scalar stays", `"hi"`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(tc.in), &v); err != nil {
|
||||
t.Fatalf("bad fixture: %v", err)
|
||||
}
|
||||
out := wrapLoneCellObject(v)
|
||||
_, isWrapped := out.([]interface{})
|
||||
_, wasArray := v.([]interface{})
|
||||
if tc.wrapped && (!isWrapped || wasArray) {
|
||||
t.Errorf("expected wrap to [[cell]], got %#v", out)
|
||||
}
|
||||
if !tc.wrapped && !wasArray && isWrapped {
|
||||
t.Errorf("expected no wrap, got %#v", out)
|
||||
}
|
||||
if tc.wrapped {
|
||||
rows, _ := out.([]interface{})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(rows))
|
||||
}
|
||||
cells, _ := rows[0].([]interface{})
|
||||
if len(cells) != 1 {
|
||||
t.Fatalf("want 1 cell, got %d", len(cells))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellObjectKeys_MatchEmbeddedSchema drift-guards the hardcoded cell
|
||||
// vocabulary against the embedded +cells-set --cells schema: if the spec
|
||||
// repo adds or removes a cell property, this fails and cellObjectKeys must
|
||||
// be updated (an outdated set only narrows the auto-wrap, but silently
|
||||
// narrowing is still drift).
|
||||
func TestCellObjectKeys_MatchEmbeddedSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagSchemas: %v", err)
|
||||
}
|
||||
raw, ok := idx.Flags["+cells-set"]["cells"]
|
||||
if !ok {
|
||||
t.Fatal("embedded schema for +cells-set --cells missing")
|
||||
}
|
||||
var schema schemaProperty
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
cell := schema.Items
|
||||
if cell != nil && cell.Items != nil {
|
||||
cell = cell.Items
|
||||
}
|
||||
if cell == nil || len(cell.Properties) == 0 {
|
||||
t.Fatal("schema shape changed: expected array→array→object with properties")
|
||||
}
|
||||
for k := range cell.Properties {
|
||||
if _, ok := cellObjectKeys[k]; !ok {
|
||||
t.Errorf("schema property %q missing from cellObjectKeys", k)
|
||||
}
|
||||
}
|
||||
for k := range cellObjectKeys {
|
||||
if _, ok := cell.Properties[k]; !ok {
|
||||
t.Errorf("cellObjectKeys has %q which the schema no longer declares", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_LoneCellObjectAutoWraps runs the mounted path end-to-end: the
|
||||
// eval-trace failure shape (--cells with a bare object) now dry-runs clean
|
||||
// instead of failing "expected type array, got object".
|
||||
func TestCellsSet_LoneCellObjectAutoWraps(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `{"value":"hello"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("lone cell object should auto-wrap to [[cell]], got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Errorf("dry-run body should carry the cell value, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_BorderWeightWordInStyleNormalizes pins the reachability
|
||||
// fix for the border acceptance layer on the --border-styles flag path: the
|
||||
// eval-trace failure shape ({"style":"thin"} — 07-28 root-cause report #2,
|
||||
// 173 occurrences) must normalize to style:solid + weight:thin BEFORE the
|
||||
// schema enum check, instead of dying on `value "thin" is not in enum`.
|
||||
func TestCellsSetStyle_BorderWeightWordInStyleNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
|
||||
t.Run("full nested form with weight word in style", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:B2",
|
||||
"--border-styles", `{"top":{"style":"thin","color":"#B4B4B4"},"bottom":{"style":"thin","color":"#B4B4B4"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("weight word in style slot should normalize, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"style": "solid"`, `"weight": "thin"`} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %s, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all shorthand with weight word in style", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--border-styles", `{"all":{"style":"medium","color":"#000000"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("all shorthand + weight word should normalize, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"top"`, `"bottom"`, `"weight": "medium"`, `"style": "solid"`} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %s, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit conflicting weight keeps the enum error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--border-styles", `{"top":{"style":"thin","weight":"thick"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "not in enum")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSet_BorderWeightWordInStyleNormalizes pins the same reachability
|
||||
// fix on the typed --cells carrier (07-28 root-cause report #10, 58
|
||||
// occurrences): border_styles inside a cell object normalizes before the
|
||||
// enum check.
|
||||
func TestCellsSet_BorderWeightWordInStyleNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":"x","border_styles":{"top":{"style":"thin","color":"#000000"}}}]]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("weight word in style slot should normalize on --cells, got: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"style": "solid"`, `"weight": "thin"`} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %s, got %q", want, stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_SheetsDecodeHints pins the two decode-failure prescriptions:
|
||||
// wrong JSON kind inlines the expected shape; mangled JSON steers to
|
||||
// stdin/@file.
|
||||
func TestTablePut_SheetsDecodeHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("type mismatch inlines skeleton", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":[{"name":"a"}],"data":[]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--sheets: invalid JSON")
|
||||
for _, want := range []string{"expected shape:", `"columns":["City","Revenue"]`, `"dtypes":{"Revenue":"float64"}`} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare array names the missing envelope", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `[{"name":"s","columns":["a"],"data":[["x"]]}]`,
|
||||
"--dry-run",
|
||||
})
|
||||
// The Go unmarshal text names the internal struct, not the fix
|
||||
// (07-28 root-cause report #4, 84 occurrences).
|
||||
ve := requireValidation(t, err, `top level must be the object {"sheets":[…]}`)
|
||||
if strings.Contains(ve.Message, "cannot unmarshal") {
|
||||
t.Errorf("message should not leak the Go unmarshal wording, got %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "expected shape:") {
|
||||
t.Errorf("hint should still inline the skeleton, got %q", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("syntax error steers to stdin or @file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[)`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--sheets: invalid JSON")
|
||||
for _, want := range []string{"stdin", "@./payload.json"} {
|
||||
if !strings.Contains(ve.Hint, want) {
|
||||
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNormalizeChartHexColors pins the '#' prefixing on bare hex color
|
||||
// values (eval V2U024: bars.color "4472C4" rejected server-side) and the
|
||||
// pass-through of everything else, including the parseJSONFlag wiring for
|
||||
// the batch sub-op path.
|
||||
func TestNormalizeChartHexColors(t *testing.T) {
|
||||
t.Parallel()
|
||||
props := map[string]interface{}{
|
||||
"plotArea": map[string]interface{}{
|
||||
"plot": map[string]interface{}{
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{"bars": map[string]interface{}{"color": "4472C4"}},
|
||||
map[string]interface{}{"line": map[string]interface{}{"color": "#ED7D31"}},
|
||||
map[string]interface{}{"area": map[string]interface{}{"color": "rgba(1,2,3,0.5)"}},
|
||||
map[string]interface{}{"font_color": "ED7D31AA", "label": "not a color 4472C4"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
normalizeChartHexColors(props)
|
||||
series := props["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})["series"].([]interface{})
|
||||
if got := series[0].(map[string]interface{})["bars"].(map[string]interface{})["color"]; got != "#4472C4" {
|
||||
t.Errorf("bare hex should gain #, got %v", got)
|
||||
}
|
||||
if got := series[1].(map[string]interface{})["line"].(map[string]interface{})["color"]; got != "#ED7D31" {
|
||||
t.Errorf("already-prefixed color must not change, got %v", got)
|
||||
}
|
||||
if got := series[2].(map[string]interface{})["area"].(map[string]interface{})["color"]; got != "rgba(1,2,3,0.5)" {
|
||||
t.Errorf("rgba color must not change, got %v", got)
|
||||
}
|
||||
last := series[3].(map[string]interface{})
|
||||
if got := last["font_color"]; got != "#ED7D31AA" {
|
||||
t.Errorf("8-digit hex on a *_color key should gain #, got %v", got)
|
||||
}
|
||||
if got := last["label"]; got != "not a color 4472C4" {
|
||||
t.Errorf("non-color key must not change, got %v", got)
|
||||
}
|
||||
|
||||
// Wiring: a +chart-create sub-op style view routes through parseJSONFlag
|
||||
// and picks up the normalizer.
|
||||
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{
|
||||
"properties": map[string]interface{}{"title": map[string]interface{}{"font_color": "112233"}},
|
||||
})
|
||||
out, err := parseJSONFlag(fv, "properties")
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONFlag: %v", err)
|
||||
}
|
||||
title := out.(map[string]interface{})["title"].(map[string]interface{})
|
||||
if title["font_color"] != "#112233" {
|
||||
t.Errorf("parseJSONFlag should apply the chart color normalizer, got %v", title["font_color"])
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -29,10 +30,14 @@ import (
|
||||
// The tool's contract (post-translation):
|
||||
// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? }
|
||||
//
|
||||
// continue_on_error defaults to false (strict transaction): any failure
|
||||
// rolls back the whole batch. CLI leaves the default in place for the
|
||||
// three "fan-out" shortcuts since they're meant to be all-or-nothing;
|
||||
// only +batch-update lets callers flip it via --continue-on-error.
|
||||
// continue_on_error defaults to false (fail-fast): execution stops at the
|
||||
// first failing sub-op, but sub-ops already applied are NOT rolled back —
|
||||
// the server reports "N succeeded, M failed" and the N stay in the sheet
|
||||
// (verified against live batches; earlier docs wrongly promised a rollback,
|
||||
// which made agents resend whole batches and double-apply the successes).
|
||||
// CLI leaves the default in place for the fan-out shortcuts since they're
|
||||
// idempotent stamps; only +batch-update lets callers flip it via
|
||||
// --continue-on-error.
|
||||
|
||||
// BatchUpdate accepts a CLI-shape operations array (each item
|
||||
// {shortcut, input}); on Validate / DryRun / Execute we translate each
|
||||
@@ -42,7 +47,7 @@ import (
|
||||
var BatchUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-update",
|
||||
Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).",
|
||||
Description: "Execute a batch of write shortcuts in one request; fail-fast on the first failing sub-op (already-applied sub-ops are NOT rolled back).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -64,7 +69,11 @@ var BatchUpdate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := batchUpdateInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
if warnings := batchWarnings(runtime); len(warnings) > 0 {
|
||||
dr.Set("warning_message", strings.Join(warnings, "\n"))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -75,6 +84,9 @@ var BatchUpdate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range batchWarnings(runtime) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, w)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -83,7 +95,8 @@ var BatchUpdate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
|
||||
"high-risk-write: preview with --dry-run, get the user's explicit consent, then re-run with --yes appended — do not pass --yes before the user has confirmed (without it the call exits 10 asking for confirmation).",
|
||||
"Execution is fail-fast, NOT transactional: on \"N succeeded, M failed\" the succeeded sub-ops stay applied (no rollback) — fix the failure and resend ONLY the operations from the first failed index onward; resending the whole batch re-applies the succeeded ones. Pass --continue-on-error to keep going past failures instead.",
|
||||
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
|
||||
},
|
||||
}
|
||||
@@ -124,6 +137,171 @@ func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// batchNeedsDimInsertBeforeStyleWarning reports whether any +dim-insert sub-op
|
||||
// requests --inherit-style before at the first row/column, where the
|
||||
// preceding-side style cannot be copied (no preceding row/column exists).
|
||||
// batchWarnings collects the advisory notes a batch surfaces before it runs,
|
||||
// in one place so DryRun and Execute cannot drift apart on which ones they
|
||||
// report.
|
||||
func batchWarnings(runtime *common.RuntimeContext) []string {
|
||||
var out []string
|
||||
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
|
||||
out = append(out, dimInsertBeforeStyleWarning)
|
||||
}
|
||||
out = append(out, batchCollidingDimFreezeNotes(runtime)...)
|
||||
return append(out, batchLegacyDimFreezeNotes(runtime)...)
|
||||
}
|
||||
|
||||
// batchCollidingDimFreezeNotes reports +dim-freeze sub-ops that target the SAME
|
||||
// sheet more than once. Freeze is full-state replacement, so each of them
|
||||
// discards the previous one and only the last survives — both still report
|
||||
// success, which is exactly why the mistake goes unnoticed. The CLI has already
|
||||
// walked the whole ops array by this point, so it can name the survivor and the
|
||||
// single sub-op that holds everything the caller clearly meant to hold.
|
||||
//
|
||||
// A batch cannot read current state, and +styles-put (the other combined-freeze
|
||||
// carrier) is not batchable, so folding into ONE sub-op is the only fix — hence
|
||||
// a note rather than a suggestion to reorder.
|
||||
func batchCollidingDimFreezeNotes(runtime *common.RuntimeContext) []string {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return nil // a malformed --operations is the translator's to report.
|
||||
}
|
||||
type freezeOp struct {
|
||||
index int
|
||||
rows, cols int
|
||||
}
|
||||
// Keyed by the sub-op's sheet selector: freezes on different sheets are
|
||||
// independent. Order of first appearance keeps the notes deterministic.
|
||||
bySheet := map[string][]freezeOp{}
|
||||
var order []string
|
||||
for i, raw := range rawOps {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sc, _ := op["shortcut"].(string); sc != "+dim-freeze" {
|
||||
continue
|
||||
}
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
if input == nil {
|
||||
continue
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+dim-freeze", input)
|
||||
rows, cols, ok := dimFreezeAxes(fv)
|
||||
if !ok {
|
||||
continue // an unusable sub-op is the translator's to report.
|
||||
}
|
||||
key := strings.TrimSpace(fv.Str("sheet-id")) + "\x00" + strings.TrimSpace(fv.Str("sheet-name"))
|
||||
if _, seen := bySheet[key]; !seen {
|
||||
order = append(order, key)
|
||||
}
|
||||
bySheet[key] = append(bySheet[key], freezeOp{index: i, rows: rows, cols: cols})
|
||||
}
|
||||
|
||||
var notes []string
|
||||
for _, key := range order {
|
||||
ops := bySheet[key]
|
||||
if len(ops) < 2 {
|
||||
continue
|
||||
}
|
||||
indexes := make([]string, 0, len(ops))
|
||||
// The combined state is what the caller almost certainly meant: keep the
|
||||
// last positive value named for each axis. An axis nobody ever freezes
|
||||
// stays 0, so a deliberate "unfreeze everything" batch still renders as
|
||||
// --rows 0 --cols 0 rather than inventing a freeze.
|
||||
combinedRows, combinedCols := 0, 0
|
||||
for _, op := range ops {
|
||||
indexes = append(indexes, fmt.Sprintf("operations[%d]", op.index))
|
||||
if op.rows > 0 {
|
||||
combinedRows = op.rows
|
||||
}
|
||||
if op.cols > 0 {
|
||||
combinedCols = op.cols
|
||||
}
|
||||
}
|
||||
last := ops[len(ops)-1]
|
||||
notes = append(notes, fmt.Sprintf(
|
||||
"warning: %s are all +dim-freeze on the same sheet — freeze replaces the WHOLE state, so each one discards the previous and only %s survives (ending at %s). They all report success. Replace them with ONE sub-op: %s",
|
||||
strings.Join(indexes, ", "),
|
||||
indexes[len(indexes)-1],
|
||||
dimFreezeSpelling(last.rows, last.cols),
|
||||
dimFreezeSpelling(combinedRows, combinedCols)))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// batchLegacyDimFreezeNotes steers +dim-freeze sub-ops still written in the
|
||||
// deprecated --dimension/--count form (see DEPRECATED(phase-2) on
|
||||
// dimFreezeLegacyNote). The standalone command prints that note from its own
|
||||
// DryRun/Execute, which a sub-op never reaches — yet the batch is where the
|
||||
// legacy form does the most damage: freeze is full-state replacement, so two
|
||||
// per-axis sub-ops both report success while only the last axis stays frozen,
|
||||
// and +styles-put (the other way to set both axes) is not batchable. The
|
||||
// wording comes from the shared helper, so it cannot drift from the standalone
|
||||
// one.
|
||||
func batchLegacyDimFreezeNotes(runtime *common.RuntimeContext) []string {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return nil // a malformed --operations is the translator's to report.
|
||||
}
|
||||
var notes []string
|
||||
for i, raw := range rawOps {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sc, _ := op["shortcut"].(string); sc != "+dim-freeze" {
|
||||
continue
|
||||
}
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
if input == nil {
|
||||
continue
|
||||
}
|
||||
if note := dimFreezeLegacyNote(newMapFlagViewForCommand("+dim-freeze", input)); note != "" {
|
||||
notes = append(notes, fmt.Sprintf("operations[%d] (+dim-freeze): %s", i, note))
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func batchNeedsDimInsertBeforeStyleWarning(runtime *common.RuntimeContext) bool {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range rawOps {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sc, _ := op["shortcut"].(string)
|
||||
if sc != "+dim-insert" {
|
||||
continue
|
||||
}
|
||||
input, _ := op["input"].(map[string]interface{})
|
||||
isBefore := false
|
||||
for _, key := range []string{"inherit-style", "inherit_style", "inheritStyle"} {
|
||||
if v, _ := input[key].(string); strings.EqualFold(v, "before") {
|
||||
isBefore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isBefore {
|
||||
continue
|
||||
}
|
||||
posRaw, hasPos := input["position"]
|
||||
if !hasPos {
|
||||
continue
|
||||
}
|
||||
// Warn only at the first row/column (idx 0).
|
||||
if _, idx, err := parseA1Position(strings.TrimSpace(fmt.Sprintf("%v", posRaw))); err == nil && idx == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseBatchOperationsFlag accepts --operations as either a JSON array (the
|
||||
// operations list directly) or an envelope object { operations, continue_on_error }
|
||||
// for back-compat with the legacy --data shape. Returns the operations array.
|
||||
@@ -154,12 +332,17 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
|
||||
var CellsBatchSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-batch-set-style",
|
||||
Description: "Apply one style block to many sheet-prefixed ranges in one atomic batch.",
|
||||
Description: "Apply one style block to many sheet-prefixed ranges in one batch request (fail-fast, no rollback).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-batch-set-style"),
|
||||
Tips: []string{
|
||||
"DEPRECATED: superseded by +styles-put, whose one spec also covers merges, row/col sizes and freeze — prefer it for new work.",
|
||||
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
|
||||
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
@@ -189,6 +372,14 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// DEPRECATED(phase-2): +cells-batch-set-style — replaced by +styles-put.
|
||||
// Phase 1 (here): the command keeps working and is already retired from
|
||||
// the skill docs via bundle.json doc_hidden_shortcuts in
|
||||
// sheet-skill-spec; steer new usage to the superset in-band.
|
||||
// Phase 2 removal: drop the shortcut from spec-tables + its
|
||||
// doc_hidden_shortcuts entry, then this command and its input builder.
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"note: +cells-batch-set-style is superseded by +styles-put (one spec covers styles + merges + row/col sizes + freeze); prefer +styles-put for new work")
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -230,7 +421,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
if err := checkBatchStampBudget("ranges", totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
@@ -258,7 +449,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
|
||||
var CellsBatchClear = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-batch-clear",
|
||||
Description: "Clear content/formats across many sheet-prefixed ranges in one atomic batch (irreversible).",
|
||||
Description: "Clear content/formats across many sheet-prefixed ranges in one batch request (irreversible; fail-fast, no rollback).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -334,7 +525,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
|
||||
var DropdownUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dropdown-update",
|
||||
Description: "Install or replace one dropdown across many sheet-prefixed ranges atomically.",
|
||||
Description: "Install or replace one dropdown across many sheet-prefixed ranges in one batch request (fail-fast, no rollback).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -380,7 +571,7 @@ var DropdownUpdate = common.Shortcut{
|
||||
var DropdownDelete = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dropdown-delete",
|
||||
Description: "Clear dropdowns from many sheet-prefixed ranges atomically (irreversible).",
|
||||
Description: "Clear dropdowns from many sheet-prefixed ranges in one batch request (irreversible; fail-fast, no rollback).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -452,7 +643,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget(totalCells); err != nil {
|
||||
if err := checkBatchStampBudget("ranges", totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
@@ -484,10 +675,10 @@ const maxBatchRanges = 100
|
||||
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
|
||||
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
|
||||
// summing past it. totalCells is int64 to stay overflow-safe.
|
||||
func checkBatchStampBudget(totalCells int64) error {
|
||||
func checkBatchStampBudget(flagName string, totalCells int64) error {
|
||||
if totalCells > maxStampMatrixCells {
|
||||
return sheetsValidationForFlag("ranges",
|
||||
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
|
||||
return sheetsValidationForFlag(flagName,
|
||||
"the request expands to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
|
||||
totalCells, maxStampMatrixCells)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -58,6 +58,39 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_DimInsertInheritAfterCopiesFollowingStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"D","count":1,"inherit_style":"after"}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations length = %d, want 1", len(ops))
|
||||
}
|
||||
op := ops[0].(map[string]interface{})
|
||||
if op["tool_name"] != "modify_sheet_structure" {
|
||||
t.Fatalf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
|
||||
}
|
||||
in, _ := op["input"].(map[string]interface{})
|
||||
// inherit_style=after copies the following column's style via a plain
|
||||
// before-insert at the same position (the backend anchors on the following
|
||||
// column), so position stays D with side=before.
|
||||
assertInputEquals(t, in, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": "sh1",
|
||||
"operation": "insert",
|
||||
"position": "D",
|
||||
"count": float64(1),
|
||||
"side": "before",
|
||||
})
|
||||
}
|
||||
|
||||
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
@@ -405,6 +438,21 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
|
||||
wantMatch: "'input' must be a JSON object",
|
||||
},
|
||||
{
|
||||
name: "wrapped cell_styles structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_styles":{"background_color":"#EBF1F8"}}}]`,
|
||||
wantMatch: "do not wrap in cell_styles",
|
||||
},
|
||||
{
|
||||
name: "wrapped styles structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","styles":{"font_weight":"bold"}}}]`,
|
||||
wantMatch: "do not wrap in styles",
|
||||
},
|
||||
{
|
||||
name: "wrapped cell_merges structure",
|
||||
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_merges":[{"range":"A1:B1"}]}}]`,
|
||||
wantMatch: "do not wrap in cell_merges",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -420,6 +468,99 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper guards the
|
||||
// wrapped-structure rejection against overreach: the same style fields in
|
||||
// their correct flattened form must translate cleanly — only the wrapper
|
||||
// container keys (cell_styles / styles / cell_merges) are rejected.
|
||||
func TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := translateBatchOp(map[string]interface{}{
|
||||
"shortcut": "+cells-set-style",
|
||||
"input": map[string]interface{}{
|
||||
"sheet_name": "s",
|
||||
"range": "A1",
|
||||
"background_color": "#EBF1F8",
|
||||
"font_weight": "bold",
|
||||
},
|
||||
}, testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("flattened style keys must pass the wrapper check, got %v", err)
|
||||
}
|
||||
input := got["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{})
|
||||
if style["background_color"] != "#EBF1F8" || style["font_weight"] != "bold" {
|
||||
t.Fatalf("translated style = %#v", style)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags locks the static
|
||||
// assumption wrappedSubOpInputKeys relies on: no shortcut registered in
|
||||
// batchOpDispatch declares a flag named cell_styles / cell_merges / styles.
|
||||
// If a future dispatch-table addition (e.g. +table-put) carries one of these
|
||||
// flags, its legitimate input would be silently rejected by the wrapper
|
||||
// check — this test turns that silent breakage into a build-time failure.
|
||||
func TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
wrapped := make(map[string]struct{}, len(wrappedSubOpInputKeys))
|
||||
for _, k := range wrappedSubOpInputKeys {
|
||||
wrapped[k] = struct{}{}
|
||||
}
|
||||
for shortcut := range batchOpDispatch {
|
||||
for _, f := range flagsFor(shortcut) {
|
||||
key := strings.ReplaceAll(f.Name, "-", "_")
|
||||
if _, clash := wrapped[key]; clash {
|
||||
t.Errorf("%s declares flag --%s which collides with wrappedSubOpInputKeys; "+
|
||||
"exempt this shortcut from the wrapper check before adding it to batchOpDispatch",
|
||||
shortcut, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_AggregatesMultipleOpErrors pins op-level aggregation: when
|
||||
// several operations are invalid, one reply names them all (numbered, with
|
||||
// each op's own error) instead of failing on the first bad op only. A single
|
||||
// bad op keeps the historical single-error message (no aggregate wrapper).
|
||||
func TestBatchUpdate_AggregatesMultipleOpErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two bad ops reported together", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set-magic","input":{}},
|
||||
{"shortcut":"+cells-set","input":{"sheet_name":"s","range":"A1"}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
|
||||
]`,
|
||||
"--yes", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "2 of 3 operations failed validation")
|
||||
for _, want := range []string{"1) ", "2) ", "operations[0]", "operations[1]"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("aggregated op error should contain %q, got %q", want, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single bad op keeps plain message", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set-magic","input":{}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
|
||||
]`,
|
||||
"--yes", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "not allowed in +batch-update")
|
||||
if strings.Contains(err.Error(), "operations failed validation") {
|
||||
t.Errorf("single bad op must not get the aggregate wrapper, got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the
|
||||
// highest-frequency batch failures, so an agent can repair its payload in a
|
||||
// single retry without --help / --print-schema round trips.
|
||||
@@ -588,3 +729,145 @@ func TestSplitSheetPrefixedRange(t *testing.T) {
|
||||
// Compile-time use of json import
|
||||
_ = json.Marshal
|
||||
}
|
||||
|
||||
// TestBatchUpdate_CollidingDimFreezeWarns covers the failure mode the legacy
|
||||
// deprecation note alone could not surface: two +dim-freeze sub-ops on one
|
||||
// sheet. Freeze is full-state replacement, so the second silently discards the
|
||||
// first — and BOTH report success, which is why it goes unnoticed. Per-op
|
||||
// "equivalent to --rows 1" / "equivalent to --cols 2" notes do not say that;
|
||||
// the caller has to infer the interaction. This pins that the CLI states it.
|
||||
func TestBatchUpdate_CollidingDimFreezeWarns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("two per-axis freezes on one sheet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1}},
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","cols":2}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
for _, want := range []string{
|
||||
"operations[0], operations[1]",
|
||||
"only operations[1] survives",
|
||||
"--cols 2)", // the state actually reached
|
||||
"ONE sub-op: --rows 1 --cols 2", // the fix
|
||||
} {
|
||||
if !strings.Contains(warning, want) {
|
||||
t.Errorf("collision warning should contain %q, got %q", want, warning)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy spelling collides the same way and keeps its own note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","dimension":"row","count":1}},
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","dimension":"column","count":2}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
if !strings.Contains(warning, "ONE sub-op: --rows 1 --cols 2") {
|
||||
t.Errorf("legacy spelling should collide too, got %q", warning)
|
||||
}
|
||||
if !strings.Contains(warning, "superseded by --rows/--cols") {
|
||||
t.Errorf("per-op deprecation note should still ride along, got %q", warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different sheets do not collide", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1}},
|
||||
{"shortcut":"+dim-freeze","input":{"sheet_name":"S2","cols":2}}
|
||||
]`,
|
||||
"--yes",
|
||||
})
|
||||
if strings.Contains(warning, "same sheet") {
|
||||
t.Errorf("freezes on different sheets are independent, got %q", warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a single freeze warns about nothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1,"cols":2}}]`,
|
||||
"--yes",
|
||||
})
|
||||
if warning != "" {
|
||||
t.Errorf("one combined freeze is the correct form, got warning %q", warning)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchOpAliasCollidesWithTarget pins the message for a sub-op carrying
|
||||
// BOTH an intuitive alias and the flag it aliases. The key is recognized, so
|
||||
// reporting it as "unknown input key" (which it did, because keys are walked
|
||||
// in sorted order and "size" sorts before "width", leaving nothing to conflict
|
||||
// with yet) sent the caller looking for a typo that was not there.
|
||||
func TestBatchOpAliasCollidesWithTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("conflicting values name both spellings", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]interface{}{"sheet_name": "S1", "range": "A:C", "size": float64(100), "width": float64(120)}
|
||||
err := normalizeSubOpInputKeys("+cols-resize", input)
|
||||
if err == nil {
|
||||
t.Fatal("want an error for size + width with different values")
|
||||
}
|
||||
for _, want := range []string{`"size"`, `"width"`, "same flag"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error should contain %q, got %q", want, err.Error())
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "unknown input key") {
|
||||
t.Errorf("an aliased key is not unknown, got %q", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same value under both spellings drops the alias", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := map[string]interface{}{"sheet_name": "S1", "range": "A:C", "size": float64(120), "width": float64(120)}
|
||||
if err := normalizeSubOpInputKeys("+cols-resize", input); err != nil {
|
||||
t.Fatalf("identical values are harmless, got %v", err)
|
||||
}
|
||||
if _, still := input["size"]; still {
|
||||
t.Errorf("the alias should be dropped, got %#v", input)
|
||||
}
|
||||
if input["width"] != float64(120) {
|
||||
t.Errorf("width = %#v, want 120", input["width"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBatchUpdate_AggregatedErrorsKeepHints pins that folding several bad
|
||||
// sub-ops into one message does not cost the caller the per-shortcut key
|
||||
// contract each single-op error carries — otherwise the more mistakes you
|
||||
// make, the less guidance you get.
|
||||
func TestBatchUpdate_AggregatedErrorsKeepHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL, "--yes",
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set","input":{"sheet_name":"S1","bogus":1}},
|
||||
{"shortcut":"+cells-clear","input":{"sheet_name":"S1","nope":2}}
|
||||
]`,
|
||||
})
|
||||
ve := requireValidation(t, err, "2 of 2 operations failed validation")
|
||||
for _, want := range []string{
|
||||
"+cells-set input keys:",
|
||||
"+cells-clear input keys:",
|
||||
} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Errorf("aggregated message should inline %q, got %q", want, ve.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"high-risk-write — always preview with --dry-run; clear is not undoable.",
|
||||
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
|
||||
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
|
||||
},
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
|
||||
var RowsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+rows-resize",
|
||||
Description: "Resize rows in pixels: --range + --height <px> for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one atomic call, or --type standard/auto (--range is 1-based A1 like \"2:10\" or \"5\").",
|
||||
Description: "Resize rows in pixels: --range + --height <px> for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one batch request, or --type standard/auto (--range is 1-based A1 like \"2:10\" or \"5\").",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -260,15 +260,19 @@ var RowsResize = common.Shortcut{
|
||||
var ColsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cols-resize",
|
||||
Description: "Resize columns in pixels (NOT Excel char units): --range + --width <px> for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one atomic call, or --type standard to reset (--range is column letters like \"A:E\" or \"C\"; no auto for cols).",
|
||||
Description: "Resize columns in pixels (NOT Excel char units): --range + --width <px> for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one batch request, or --type standard to reset (--range is column letters like \"A:E\" or \"C\"; no auto for cols).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cols-resize"),
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +cols-resize --url <URL> --sheet-name Sheet1 --range A:C --width 120",
|
||||
`Different widths per column in one batch request: --widths '{"A":80,"C:E":120}'. Widths are pixels (px ≈ chars × 8 + 16), not Excel character units.`,
|
||||
},
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: resizeDryRun("column"),
|
||||
Execute: resizeExecute("column"),
|
||||
}
|
||||
|
||||
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall
|
||||
|
||||
@@ -69,8 +69,7 @@ var CellsGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, out)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,17 +87,19 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
|
||||
// read cap. Pin cell_limit very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["cell_limit"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
|
||||
// tool's two coarse switches:
|
||||
// tool's switches:
|
||||
//
|
||||
// - include_styles (bool) — toggled by "style" presence
|
||||
// - value_render_option (enum) — "formula" → formula; otherwise omitted
|
||||
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
|
||||
// the tool estimate and return per-cell isRowTruncated / isColTruncated
|
||||
//
|
||||
// "value", "comment", and "data_validation" are always returned by the tool
|
||||
// per the schema; they have no dedicated knob today but are accepted in
|
||||
@@ -119,6 +120,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
|
||||
if want["formula"] {
|
||||
input["value_render_option"] = "formula"
|
||||
}
|
||||
if want["truncation"] {
|
||||
input["include_truncation_info"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
|
||||
@@ -139,9 +143,6 @@ var CsvGet = common.Shortcut{
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return sheetsValidationForFlag("range", "--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -165,16 +166,25 @@ var CsvGet = common.Shortcut{
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return emitReadResult(runtime, out)
|
||||
},
|
||||
}
|
||||
|
||||
// csvGetFullSheetRange is the range sent when --range is omitted: the tool
|
||||
// requires one, but clips anything past the grid bounds and reports the clip
|
||||
// in actual_range — so an over-wide whole-columns range reads the entire
|
||||
// sheet in one call, with no workbook-info pre-flight. Eval traces show
|
||||
// "read the whole sheet" as a recurring intent (--range was the single most
|
||||
// missed required flag once the rest of the surface was fixed).
|
||||
const csvGetFullSheetRange = "A:ZZZ"
|
||||
|
||||
func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{"excel_id": token}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
|
||||
input["range"] = r
|
||||
} else {
|
||||
input["range"] = csvGetFullSheetRange
|
||||
}
|
||||
if runtime.Bool("skip-hidden") {
|
||||
input["skip_hidden"] = true
|
||||
@@ -183,7 +193,7 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin
|
||||
// read cap. Pin max_rows very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["max_rows"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
|
||||
@@ -34,6 +34,65 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-get include=formula without style pins include_styles=false",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "formula"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"include_styles": false,
|
||||
"value_render_option": "formula",
|
||||
"cell_limit": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
{
|
||||
// --include truncation toggles include_truncation_info so the tool
|
||||
// estimates and returns per-cell isRowTruncated / isColTruncated.
|
||||
name: "+cells-get include=truncation",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "truncation"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"include_styles": false,
|
||||
"include_truncation_info": true,
|
||||
"cell_limit": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
{
|
||||
// --output-path alone raises the cap to the bounded file-offload
|
||||
// default — NOT the unbounded sentinel; the read path is not
|
||||
// streaming, so the cap is the OOM guard.
|
||||
name: "+cells-get output-path uses bounded offload cap",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--output-path", "out.json"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"max_chars": float64(outputPathReadLimit),
|
||||
},
|
||||
},
|
||||
{
|
||||
// An explicit --max-chars survives --output-path instead of being
|
||||
// silently replaced by the unbounded sentinel.
|
||||
name: "+cells-get explicit max-chars survives output-path",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--output-path", "out.json", "--max-chars", "12345"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"max_chars": float64(12345),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Canonical form: --sheet-id + bare --range. Aligned with
|
||||
// +cells-get / +csv-get; before the e2e BUG-019 fix this
|
||||
@@ -92,7 +151,9 @@ func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
|
||||
|
||||
// TestReadData_RequiresRange covers the trim-based --range guard on the
|
||||
// single-range readers (--range "" slips past cobra's MarkFlagRequired but
|
||||
// must still be rejected by Validate).
|
||||
// must still be rejected by Validate). +csv-get is deliberately absent:
|
||||
// its --range is optional — omitted/blank means a whole-sheet read (see
|
||||
// TestCsvGet_RangeOptionalDefaultsToFullSheet).
|
||||
func TestReadData_RequiresRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -100,7 +161,6 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
sc common.Shortcut
|
||||
}{
|
||||
{"+cells-get", CellsGet},
|
||||
{"+csv-get", CsvGet},
|
||||
{"+dropdown-get", DropdownGet},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -114,6 +174,23 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCsvGet_RangeOptionalDefaultsToFullSheet pins the whole-sheet default:
|
||||
// with --range omitted the request carries the over-wide clip range, so a
|
||||
// full read needs no workbook-info pre-flight (eval: --range was the most
|
||||
// missed required flag on +csv-get once the rest of the surface settled).
|
||||
func TestCsvGet_RangeOptionalDefaultsToFullSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, _, err := runShortcutCapturingErr(t, CsvGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rangeless +csv-get must pass validation, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, csvGetFullSheetRange) {
|
||||
t.Fatalf("dry-run body should carry the full-sheet range %q, got %q", csvGetFullSheetRange, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping
|
||||
// directly (white-box).
|
||||
func TestInfoTypeFromInclude(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -128,12 +129,29 @@ var DimInsert = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-insert"),
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
|
||||
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
|
||||
},
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
switch {
|
||||
case dimInsertNeedsBeforeStyleWarning(runtime):
|
||||
dr.Set("warning_message", dimInsertBeforeStyleWarning)
|
||||
case dimInsertAnchorShifted(runtime, input):
|
||||
// --inherit-style before anchors one unit earlier (see
|
||||
// dimInsertInput), so the previewed body carries a position the
|
||||
// caller never typed. Unexplained, that reads as an off-by-one bug in
|
||||
// exactly the artifact people dry-run to check for off-by-one bugs.
|
||||
dr.Set("warning_message", fmt.Sprintf(
|
||||
"note: the previewed position is %q, not the %q you passed — this is not an off-by-one. --inherit-style before is emulated by anchoring one row/column earlier and inserting after it, which lands in the same place while copying the PRECEDING style. The row/column still appears at %q.",
|
||||
input["position"], strings.TrimSpace(runtime.Str("position")), strings.TrimSpace(runtime.Str("position"))))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -148,6 +166,9 @@ var DimInsert = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dimInsertNeedsBeforeStyleWarning(runtime) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -157,8 +178,41 @@ var DimInsert = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// dimInsertBeforeStyleWarning fires only when the preceding-side style cannot
|
||||
// be copied: --inherit-style before at the first row/column, where no
|
||||
// preceding row/column exists. The row/column is still inserted before
|
||||
// --position, just without style inheritance. (--inherit-style after has no
|
||||
// such edge — a plain before-insert always has a following row/column.)
|
||||
const dimInsertBeforeStyleWarning = "warning: --inherit-style before cannot copy the preceding row/column's style at the first row/column (no preceding row/column exists); inserting before --position without style inheritance. Copy styles separately if needed."
|
||||
|
||||
// dimInsertAnchorShifted reports whether the built body carries an anchor
|
||||
// position different from the one the caller passed — true exactly when the
|
||||
// --inherit-style before emulation moved it back one unit. Compared against the
|
||||
// built input rather than recomputed, so the note can never claim a shift the
|
||||
// request does not have.
|
||||
func dimInsertAnchorShifted(runtime flagView, input map[string]interface{}) bool {
|
||||
built, ok := input["position"].(string)
|
||||
return ok && built != strings.TrimSpace(runtime.Str("position"))
|
||||
}
|
||||
|
||||
func dimInsertNeedsBeforeStyleWarning(runtime flagView) bool {
|
||||
if !runtime.Changed("inherit-style") || runtime.Str("inherit-style") != "before" {
|
||||
return false
|
||||
}
|
||||
// Only the first row/column (idx 0) has no preceding row/column.
|
||||
_, idx, err := parseA1Position(strings.TrimSpace(runtime.Str("position")))
|
||||
return err == nil && idx == 0
|
||||
}
|
||||
|
||||
// dimInsertInput passes --position (1-based row number "3" or column letter
|
||||
// "C") straight to the tool's `position` field; --count maps to `count`.
|
||||
// "C") to the tool's `position` field; --count maps to `count`.
|
||||
//
|
||||
// +dim-insert's public contract is always "insert before --position";
|
||||
// --inherit-style only selects which side's style the new row/column copies,
|
||||
// never the insertion side. The sheet-ai tool always copies the *anchor*
|
||||
// column's style (the target passed as position), regardless of side — so
|
||||
// --inherit-style before is emulated by anchoring one unit earlier. See the
|
||||
// switch below.
|
||||
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -184,11 +238,36 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
|
||||
"count": count,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
// --inherit-style selects which side's style the blank row/column copies;
|
||||
// the insertion always lands *before* --position. Empirically the addCol
|
||||
// backend copies the *anchor* column's style (the target passed as
|
||||
// position), regardless of side — side only decides whether the blank lands
|
||||
// before or after that anchor (verified live, see
|
||||
// TestDimInsertInheritStyleSideMapping):
|
||||
// after → side=before at P: the blank lands at P and anchor P becomes the
|
||||
// *following* neighbour, so the blank copies it. Position unchanged.
|
||||
// before → side=after at P-1: the blank still lands at P (insert-after-(P-1)
|
||||
// == insert-before-P) and anchor P-1 becomes the *preceding*
|
||||
// neighbour, so the blank copies it.
|
||||
//
|
||||
// The flag documents `after` as its default, and the omitted case takes that
|
||||
// branch rather than leaving `side` off the request. This is belt-and-braces,
|
||||
// not a fix: the backend's own default IS `before`, verified live 07-31 on a
|
||||
// 4-way sheet (omitted / after / before / no-side-at-all all place the blank
|
||||
// at --position, and omitted inherits the FOLLOWING row's style exactly as
|
||||
// `after` does). Sending it explicitly just stops the documented default from
|
||||
// depending on an undocumented server-side one.
|
||||
// Pinned by TestDimInsertOmittedMatchesAfter.
|
||||
switch runtime.Str("inherit-style") {
|
||||
case "before":
|
||||
if prev, ok := a1PositionBefore(position); ok {
|
||||
input["side"] = "after"
|
||||
input["position"] = prev
|
||||
}
|
||||
// First row/column: no preceding row/column exists, so fall back to a
|
||||
// plain before-insert (dimInsertNeedsBeforeStyleWarning surfaces this).
|
||||
default: // "after", and the omitted case it is the default for.
|
||||
input["side"] = "before"
|
||||
case "after":
|
||||
input["side"] = "after"
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -203,10 +282,34 @@ var DimDelete = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-delete"),
|
||||
Validate: validateDimRangeOp("delete"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("ranges") {
|
||||
if runtime.Changed("range") {
|
||||
return sheetsValidationForFlag("ranges", "--range and --ranges are mutually exclusive; put every range into --ranges")
|
||||
}
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
return err
|
||||
}
|
||||
return validateDimRangeOp("delete")(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
if runtime.Changed("ranges") {
|
||||
ops, _ := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
}
|
||||
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
@@ -219,6 +322,21 @@ var DimDelete = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("ranges") {
|
||||
ops, err := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -232,9 +350,76 @@ var DimDelete = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
"Row/column deletion is irreversible. Always preview with --dry-run first.",
|
||||
`Scattered ranges: --ranges '["5:5","8:8","11:13"]' deletes them in one batch request (fail-fast, no rollback) — the CLI orders positions descending, so indexes never shift under you.`,
|
||||
},
|
||||
}
|
||||
|
||||
// dimDeleteRangesOps parses --ranges into one atomic batch of
|
||||
// modify_sheet_structure delete ops, ordered DESCENDING by start position:
|
||||
// deleting an earlier row shifts every later index up, so ascending
|
||||
// execution deletes the wrong rows — the recurring failure of hand-built
|
||||
// dim-delete batches in eval traces. Same-dimension and non-overlap are
|
||||
// enforced up front.
|
||||
func dimDeleteRangesOps(runtime flagView, token, sheetID, sheetName string) ([]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := requireJSONArray(runtime, "ranges")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges must be a non-empty JSON array")
|
||||
}
|
||||
if len(raw) > maxBatchRanges {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(raw))
|
||||
}
|
||||
type span struct {
|
||||
raw string
|
||||
start, end int
|
||||
}
|
||||
spans := make([]span, 0, len(raw))
|
||||
dimension := ""
|
||||
for i, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i)
|
||||
}
|
||||
dim, start, end, err := parseA1Range(s)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q: %v", i, s, err)
|
||||
}
|
||||
if dimension == "" {
|
||||
dimension = dim
|
||||
} else if dim != dimension {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q is a %s range but earlier entries are %s ranges; one call deletes rows OR columns, not both", i, s, dim, dimension)
|
||||
}
|
||||
spans = append(spans, span{raw: strings.TrimSpace(s), start: start, end: end})
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].start > spans[j].start })
|
||||
for i := 1; i < len(spans); i++ {
|
||||
// Descending order: spans[i-1] starts at or after spans[i]. Overlap
|
||||
// (or duplicate) makes the later delete hit already-shifted positions.
|
||||
if spans[i].end >= spans[i-1].start {
|
||||
return nil, sheetsValidationForFlag("ranges", "--ranges entries %q and %q overlap; merge them into one range", spans[i].raw, spans[i-1].raw)
|
||||
}
|
||||
}
|
||||
ops := make([]interface{}, 0, len(spans))
|
||||
for _, sp := range spans {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "delete",
|
||||
"range": sp.raw,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "modify_sheet_structure",
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// validateDimRangeOp returns a Validate closure that delegates to
|
||||
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
|
||||
// extra `op` argument. Token check happens here; the rest is the builder.
|
||||
@@ -281,23 +466,37 @@ var DimUngroup = newDimGroupShortcut(
|
||||
"+dim-ungroup", "Remove a row/column outline group.", "ungroup",
|
||||
)
|
||||
|
||||
// DimFreeze freezes the first N rows or columns; --count 0 unfreezes that
|
||||
// dimension.
|
||||
// DimFreeze sets the sheet's freeze state. Freeze is full-state replacement
|
||||
// server-side (verified 07-31 live), so every call states the WHOLE state:
|
||||
// --rows/--cols name both axes at once, while the older --dimension/--count
|
||||
// pair can only name one and therefore unfreezes the other.
|
||||
var DimFreeze = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dim-freeze",
|
||||
Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.",
|
||||
Description: "Freeze the first N rows and/or columns; this sets the whole freeze state, so an axis you do not name ends up unfrozen.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-freeze"),
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --rows 1 --cols 2 (holds the header row and the first 2 columns in one call)",
|
||||
"Freezing is not additive: --dimension row --count 1 followed by --dimension column --count 2 leaves ONLY the columns frozen. Pass --rows/--cols together instead of calling twice",
|
||||
"To unfreeze one axis but keep the other, state the survivor: --rows 0 --cols 2. Bare --count 0 clears both",
|
||||
},
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimFreezeInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
// Surface the deprecation steer during the preview too: agents dry-run
|
||||
// before executing, so a note only on the execute path arrives after the
|
||||
// spelling is already committed to.
|
||||
if note := dimFreezeLegacyNote(runtime); note != "" {
|
||||
dr.Set("warning_message", note)
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
@@ -312,6 +511,9 @@ var DimFreeze = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if note := dimFreezeLegacyNote(runtime); note != "" {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, note)
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -321,33 +523,151 @@ var DimFreeze = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// DEPRECATED(phase-2): +dim-freeze --dimension / --count — replaced by
|
||||
// --rows / --cols. Phase 1: the flags keep working, are retired from the skill
|
||||
// docs via bundle.json doc_hidden_flags in sheet-skill-spec and from --help via
|
||||
// their hidden mark, and every use is steered by dimFreezeLegacyNote.
|
||||
// Phase 2 removal: drop both rows from spec-tables/flags.json + their
|
||||
// doc_hidden_flags entry, then dimFreezeLegacyNote, dimFreezeEquivalent, their
|
||||
// call sites (this shortcut's DryRun/Execute and batchLegacyDimFreezeNotes) and
|
||||
// the legacy branch in dimFreezeInput.
|
||||
//
|
||||
// The pair is a strict subset of --rows/--cols — every --dimension/--count call
|
||||
// has a byte-identical --rows/--cols spelling (TestDimFreezeEquivalent pins
|
||||
// this) — and it is the form that reads as if it scoped to one axis when the
|
||||
// backend replaces the whole freeze state.
|
||||
//
|
||||
// dimFreezeLegacyNote returns "" for the modern form. It takes a flagView
|
||||
// rather than a RuntimeContext so +batch-update can render the identical
|
||||
// wording for a sub-op (see batchLegacyDimFreezeNotes).
|
||||
func dimFreezeLegacyNote(runtime flagView) string {
|
||||
if !runtime.Changed("dimension") && !runtime.Changed("count") {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"note: --dimension/--count is superseded by --rows/--cols, which state both axes at once; this call is equivalent to %s",
|
||||
dimFreezeEquivalent(runtime))
|
||||
}
|
||||
|
||||
// dimFreezeEquivalent renders the --rows/--cols spelling of a legacy
|
||||
// --dimension/--count call, so the deprecation note carries the exact
|
||||
// replacement instead of a generic pointer.
|
||||
func dimFreezeEquivalent(runtime flagView) string {
|
||||
rows, cols, _ := dimFreezeAxes(runtime)
|
||||
return dimFreezeSpelling(rows, cols)
|
||||
}
|
||||
|
||||
// dimFreezeAxes maps either request form onto the (rows, cols) freeze state it
|
||||
// asks for. Pure mapping, no validation — dimFreezeInput validates first and
|
||||
// then calls this, so the request body, the deprecation note and the batch
|
||||
// collision note can never disagree about what a call means. ok is false when
|
||||
// the flags name no state at all, or when the legacy pair is half-given
|
||||
// (--count without --dimension); dimFreezeInput reports both.
|
||||
func dimFreezeAxes(runtime flagView) (rows, cols int, ok bool) {
|
||||
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
|
||||
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
|
||||
switch {
|
||||
case axisForm && !pairForm:
|
||||
return runtime.Int("rows"), runtime.Int("cols"), true
|
||||
case pairForm && !axisForm:
|
||||
if !runtime.Changed("dimension") || !runtime.Changed("count") {
|
||||
return 0, 0, false
|
||||
}
|
||||
// A zero count clears BOTH axes — it is the bare unfreeze operation,
|
||||
// which carries no dimension.
|
||||
if count := runtime.Int("count"); count > 0 {
|
||||
if runtime.Str("dimension") == "row" {
|
||||
return count, 0, true
|
||||
}
|
||||
return 0, count, true
|
||||
}
|
||||
return 0, 0, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// dimFreezeSpelling renders a freeze state as the --rows/--cols flags that
|
||||
// produce it. Single source of the replacement wording, shared by the
|
||||
// deprecation note and the batch collision note.
|
||||
func dimFreezeSpelling(rows, cols int) string {
|
||||
switch {
|
||||
case rows > 0 && cols > 0:
|
||||
return fmt.Sprintf("--rows %d --cols %d", rows, cols)
|
||||
case rows > 0:
|
||||
return fmt.Sprintf("--rows %d", rows)
|
||||
case cols > 0:
|
||||
return fmt.Sprintf("--cols %d", cols)
|
||||
}
|
||||
return "--rows 0 --cols 0"
|
||||
}
|
||||
|
||||
// dimFreezeInput builds the freeze body for both the standalone shortcut and
|
||||
// the +batch-update sub-op, so the two stay byte-identical (see
|
||||
// TestBatchOp_BodyMatchesStandalone).
|
||||
//
|
||||
// Two request forms, deliberately not mixable:
|
||||
//
|
||||
// - --rows / --cols state the complete target state in ONE operation. This
|
||||
// is the only form that can hold both axes, because freeze is full-state
|
||||
// replacement server-side (verified 07-31 live: freeze rows=1 then
|
||||
// columns=2 in two calls ends at 0 rows / 2 columns — the second call
|
||||
// drops the first axis). It is also the only form usable inside
|
||||
// +batch-update, whose sub-ops are a static array that cannot read the
|
||||
// current state to preserve an axis.
|
||||
// - --dimension + --count is the original single-axis form, kept for
|
||||
// compatibility. It necessarily unfreezes the axis it does not name.
|
||||
func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("dimension") {
|
||||
return nil, sheetsValidationForFlag("dimension", "--dimension is required")
|
||||
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
|
||||
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
|
||||
switch {
|
||||
case pairForm && axisForm:
|
||||
return nil, sheetsValidationForFlag("rows",
|
||||
"give either --rows/--cols or --dimension/--count, not both — they are two ways to say the same thing; --rows/--cols is the one that can hold both axes at once")
|
||||
case !pairForm && !axisForm:
|
||||
// Prescribes only --rows/--cols: --dimension/--count is retired
|
||||
// (DEPRECATED(phase-2)) and steering a caller into it here would earn
|
||||
// them a deprecation note on the very next call.
|
||||
return nil, sheetsValidationForFlag("rows",
|
||||
"nothing to freeze: pass --rows N and/or --cols N — e.g. --rows 1 holds the header row, --rows 1 --cols 2 holds it plus the first 2 columns, --rows 0 --cols 0 unfreezes everything")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, sheetsValidationForFlag("count", "--count is required (0 unfreezes)")
|
||||
}
|
||||
if runtime.Int("count") < 0 {
|
||||
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
|
||||
}
|
||||
dim := runtime.Str("dimension")
|
||||
count := runtime.Int("count")
|
||||
op := "freeze"
|
||||
if count == 0 {
|
||||
op = "unfreeze"
|
||||
}
|
||||
input := map[string]interface{}{"excel_id": token, "operation": op}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if op == "freeze" {
|
||||
if dim == "row" {
|
||||
input["freeze_rows"] = count
|
||||
} else {
|
||||
input["freeze_columns"] = count
|
||||
|
||||
if axisForm {
|
||||
for _, name := range []string{"rows", "cols"} {
|
||||
if runtime.Changed(name) && runtime.Int(name) < 0 {
|
||||
return nil, sheetsValidationForFlag(name, "--%s must be >= 0 (0 leaves that axis unfrozen)", name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !runtime.Changed("dimension") {
|
||||
return nil, sheetsValidationForFlag("dimension", "--dimension is required alongside --count (or use --rows/--cols to set both axes at once)")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, sheetsValidationForFlag("count", "--count is required alongside --dimension (0 unfreezes)")
|
||||
}
|
||||
if runtime.Int("count") < 0 {
|
||||
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
|
||||
}
|
||||
}
|
||||
// Validation done; the flags-to-state mapping is dimFreezeAxes', shared with
|
||||
// the deprecation and collision notes so the three cannot disagree.
|
||||
rows, cols, _ := dimFreezeAxes(runtime)
|
||||
|
||||
// An all-zero target is the bare "unfreeze" operation, which carries no
|
||||
// dimension and clears everything — the same request the old --count 0
|
||||
// always sent.
|
||||
input := map[string]interface{}{"excel_id": token, "operation": "unfreeze"}
|
||||
if rows > 0 || cols > 0 {
|
||||
input["operation"] = "freeze"
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if rows > 0 {
|
||||
input["freeze_rows"] = rows
|
||||
}
|
||||
if cols > 0 {
|
||||
input["freeze_columns"] = cols
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -557,6 +877,23 @@ func columnIndexToLetter(idx int) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// a1PositionBefore returns the A1 position one unit before s ("6" → "5",
|
||||
// "C" → "B"), preserving row/column form. ok is false when s is the first
|
||||
// row/column (row 1 / column A) — no earlier position — or is not a valid A1
|
||||
// position. Callers validate via parseA1Position first, so in practice ok is
|
||||
// false only at the first row/column.
|
||||
func a1PositionBefore(s string) (pos string, ok bool) {
|
||||
dimension, idx, err := parseA1Position(s)
|
||||
if err != nil || idx == 0 {
|
||||
return "", false
|
||||
}
|
||||
if dimension == "row" {
|
||||
// idx is 0-based; the 1-based number one row earlier is idx itself.
|
||||
return strconv.Itoa(idx), true
|
||||
}
|
||||
return columnIndexToLetter(idx - 1), true
|
||||
}
|
||||
|
||||
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
|
||||
//
|
||||
// Moves a contiguous block of rows or columns to a new index in the same
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -48,6 +50,8 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// --inherit-style before copies the preceding row: anchor row 5 and
|
||||
// insert after it (side=after), so the blank still lands before row 6.
|
||||
name: "+dim-insert row position=6 count=3 inherit-before",
|
||||
sc: DimInsert,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"},
|
||||
@@ -56,9 +60,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": "6",
|
||||
"position": "5",
|
||||
"count": float64(3),
|
||||
"side": "before",
|
||||
"side": "after",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -133,6 +137,47 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
// The whole point of --rows/--cols: both axes in ONE operation.
|
||||
// Two single-axis calls would leave only the last axis frozen,
|
||||
// because freeze is full-state replacement server-side.
|
||||
name: "+dim-freeze --rows 1 --cols 2 → one combined op",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "1", "--cols", "2"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "freeze",
|
||||
"sheet_id": testSheetID,
|
||||
"freeze_rows": float64(1),
|
||||
"freeze_columns": float64(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Stating the survivor is how you unfreeze one axis and keep the
|
||||
// other; a zero axis is simply omitted from the body.
|
||||
name: "+dim-freeze --rows 0 --cols 2 → columns only",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "0", "--cols", "2"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "freeze",
|
||||
"sheet_id": testSheetID,
|
||||
"freeze_columns": float64(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-freeze --rows 0 --cols 0 → unfreeze",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "0", "--cols", "0"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "unfreeze",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-group row 1:5 fold",
|
||||
sc: DimGroup,
|
||||
@@ -169,6 +214,127 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDimInsertInheritStyleSideMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
position string
|
||||
inherit string
|
||||
wantPosition string
|
||||
wantSide string
|
||||
wantSideSet bool
|
||||
}{
|
||||
{
|
||||
name: "after copies the following style with a plain before-insert, position unchanged",
|
||||
position: "D",
|
||||
inherit: "after",
|
||||
wantPosition: "D",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before anchors one column earlier (side=after) to copy the preceding style",
|
||||
position: "D",
|
||||
inherit: "before",
|
||||
wantPosition: "C",
|
||||
wantSide: "after",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before on a row anchors one row earlier",
|
||||
position: "6",
|
||||
inherit: "before",
|
||||
wantPosition: "5",
|
||||
wantSide: "after",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
name: "before at the first column falls back to a plain before-insert",
|
||||
position: "A",
|
||||
inherit: "before",
|
||||
wantPosition: "A",
|
||||
wantSideSet: false,
|
||||
},
|
||||
{
|
||||
name: "after at the first column still works (before-insert anchors the following)",
|
||||
position: "A",
|
||||
inherit: "after",
|
||||
wantPosition: "A",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
{
|
||||
// The flag documents `after` as its default, so omitting it must
|
||||
// build the same body rather than leaving `side` to the backend's
|
||||
// own default — see TestDimInsertOmittedMatchesAfter.
|
||||
name: "default (flag omitted) sends the same side as --inherit-style after",
|
||||
position: "D",
|
||||
wantPosition: "D",
|
||||
wantSide: "before",
|
||||
wantSideSet: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
args := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", tc.position, "--count", "1"}
|
||||
if tc.inherit != "" {
|
||||
args = append(args, "--inherit-style", tc.inherit)
|
||||
}
|
||||
body := parseDryRunBody(t, DimInsert, args)
|
||||
got := decodeToolInput(t, body, "modify_sheet_structure")
|
||||
assertInputEquals(t, got, map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": tc.wantPosition,
|
||||
"count": float64(1),
|
||||
})
|
||||
|
||||
gv, ok := got["side"]
|
||||
if ok != tc.wantSideSet {
|
||||
t.Fatalf("side presence = %v, want %v (input=%#v)", ok, tc.wantSideSet, got)
|
||||
}
|
||||
if ok && gv != tc.wantSide {
|
||||
t.Fatalf("side = %v, want %q", gv, tc.wantSide)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimInsertOmittedMatchesAfter pins the contract --inherit-style's flag
|
||||
// description states: omitting it is the same call as passing `after`.
|
||||
//
|
||||
// Verified live 07-31 rather than assumed: on a sheet with row2 red and row3
|
||||
// blue, inserting at --position 3 places the blank at row 3 in all four
|
||||
// spellings (omitted with no `side` field at all, omitted, `after`, `before`),
|
||||
// and the blank inherits the FOLLOWING row's blue under omitted/`after` and the
|
||||
// PRECEDING row's red under `before`. So the backend's own default for `side`
|
||||
// is "before" and the pre-existing behaviour was already correct; the CLI sends
|
||||
// the field explicitly only so the documented default stops depending on an
|
||||
// undocumented server-side one. This test locks the two bodies together, byte
|
||||
// for byte, so that stays true.
|
||||
func TestDimInsertOmittedMatchesAfter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, position := range []string{"1", "3", "A", "D"} {
|
||||
t.Run("position "+position, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", position, "--count", "1"}
|
||||
omitted := decodeToolInput(t, parseDryRunBody(t, DimInsert, base), "modify_sheet_structure")
|
||||
explicit := decodeToolInput(t,
|
||||
parseDryRunBody(t, DimInsert, append(append([]string{}, base...), "--inherit-style", "after")),
|
||||
"modify_sheet_structure")
|
||||
if !reflect.DeepEqual(omitted, explicit) {
|
||||
t.Fatalf("omitted --inherit-style built %#v, --inherit-style after built %#v; they must be identical", omitted, explicit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimRange_Validation covers the A1 range parser's edge cases routed
|
||||
// through +dim-hide (any --range shortcut works; we just need to exercise
|
||||
// the validator).
|
||||
@@ -204,6 +370,233 @@ func TestDimRange_Validation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimFreezeEquivalent pins the replacement spelling printed by the
|
||||
// phase-1 deprecation note: it must be the exact --rows/--cols call the user
|
||||
// should switch to, not a generic pointer. Each pairing is also asserted for
|
||||
// body equality, which is what makes the legacy form strictly redundant.
|
||||
func TestDimFreezeEquivalent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
dimension string
|
||||
count int
|
||||
want string
|
||||
}{
|
||||
{"row", 2, "--rows 2"},
|
||||
{"column", 3, "--cols 3"},
|
||||
{"row", 0, "--rows 0 --cols 0"},
|
||||
{"column", 0, "--rows 0 --cols 0"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
legacy := newMapFlagViewForCommand("+dim-freeze", map[string]interface{}{
|
||||
"dimension": tt.dimension, "count": tt.count,
|
||||
})
|
||||
if got := dimFreezeEquivalent(legacy); got != tt.want {
|
||||
t.Fatalf("dimFreezeEquivalent = %q, want %q", got, tt.want)
|
||||
}
|
||||
// The advertised replacement must produce the identical body.
|
||||
modern := map[string]interface{}{}
|
||||
if tt.count > 0 {
|
||||
if tt.dimension == "row" {
|
||||
modern["rows"] = tt.count
|
||||
} else {
|
||||
modern["cols"] = tt.count
|
||||
}
|
||||
} else {
|
||||
modern["rows"], modern["cols"] = 0, 0
|
||||
}
|
||||
legacyInput, err := dimFreezeInput(legacy, testToken, testSheetID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("legacy form: %v", err)
|
||||
}
|
||||
modernInput, err := dimFreezeInput(newMapFlagViewForCommand("+dim-freeze", modern), testToken, testSheetID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("modern form: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(legacyInput, modernInput) {
|
||||
t.Fatalf("bodies diverge:\n legacy = %v\n modern = %v", legacyInput, modernInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetiredEnumValueMatchesOmitted pins the back-compat contract for enum
|
||||
// values this CLI retired: --inherit-style none was valid AND the default
|
||||
// before the side mapping was corrected, so rejecting it would break existing
|
||||
// scripts and any agent carrying older docs. It must behave exactly as if the
|
||||
// flag were omitted — on the standalone path and inside +batch-update alike,
|
||||
// since +dim-insert is batchable and a divergence there would be invisible.
|
||||
func TestRetiredEnumValueMatchesOmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "3", "--count", "1"}
|
||||
// Must be the registry copy: the retired-value rewrite lives in the
|
||||
// PostMount ergonomics layer, which Shortcuts() installs and the raw
|
||||
// exported var does not carry.
|
||||
dimInsert := shortcutFromRegistry(t, "+dim-insert")
|
||||
|
||||
omitted := parseDryRunBody(t, dimInsert, base)
|
||||
for _, val := range []string{"none", "NONE", "None"} {
|
||||
got := parseDryRunBody(t, dimInsert, append(append([]string{}, base...), "--inherit-style", val))
|
||||
if !reflect.DeepEqual(got, omitted) {
|
||||
t.Fatalf("--inherit-style %s body = %v, want the omitted body %v", val, got, omitted)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("still rejects a genuinely invalid value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, shortcutFromRegistry(t, "+dim-insert"),
|
||||
append(append([]string{}, base...), "--inherit-style", "banana", "--dry-run"))
|
||||
requireValidation(t, err, `invalid value "banana"`)
|
||||
})
|
||||
|
||||
t.Run("reports as absent on both paths, not just empty", func(t *testing.T) {
|
||||
// The two paths clear the value differently (cobra Set vs deleting the
|
||||
// raw key), so Changed() is the part that can silently diverge: a flag
|
||||
// whose logic reads Changed() rather than the value would then behave
|
||||
// differently standalone than inside +batch-update.
|
||||
t.Parallel()
|
||||
parent, _, _, _ := newTestRig(t, shortcutFromRegistry(t, "+dim-insert"))
|
||||
parent.SetArgs(append([]string{"+dim-insert"},
|
||||
append(append([]string{}, base...), "--inherit-style", "none", "--dry-run")...))
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
cmd, _, err := parent.Find([]string{"+dim-insert"})
|
||||
if err != nil {
|
||||
t.Fatalf("find command: %v", err)
|
||||
}
|
||||
if cmd.Flags().Changed("inherit-style") {
|
||||
t.Error("standalone: Changed() must report the retired value as absent")
|
||||
}
|
||||
|
||||
fv := newMapFlagViewForCommand("+dim-insert", map[string]interface{}{
|
||||
"position": 3, "count": 1, "inherit-style": "none",
|
||||
})
|
||||
if err := fv.normalizeAndValidateEnums(); err != nil {
|
||||
t.Fatalf("batch enum pass: %v", err)
|
||||
}
|
||||
if fv.Changed("inherit-style") {
|
||||
t.Error("batch: Changed() must report the retired value as absent")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch sub-op treats it the same", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sub := func(extra string) map[string]interface{} {
|
||||
ops := `[{"shortcut":"+dim-insert","input":{"sheet-id":"sh1","position":3,"count":1` + extra + `}}]`
|
||||
body := parseDryRunBody(t, shortcutFromRegistry(t, "+batch-update"), []string{"--url", testURL, "--operations", ops})
|
||||
input, _ := body["input"].(string)
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(input), &decoded); err != nil {
|
||||
t.Fatalf("decode batch input: %v (raw=%s)", err, input)
|
||||
}
|
||||
opsOut, _ := decoded["operations"].([]interface{})
|
||||
if len(opsOut) != 1 {
|
||||
t.Fatalf("want 1 translated op, got %v", decoded["operations"])
|
||||
}
|
||||
first, _ := opsOut[0].(map[string]interface{})
|
||||
return first
|
||||
}
|
||||
if got, want := sub(`,"inherit-style":"none"`), sub(""); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("batch sub-op with none = %v, want the omitted form %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDimFreezeLegacyNote pins WHERE the phase-1 deprecation steer appears.
|
||||
// The note used to fire only from the standalone Execute, which missed the two
|
||||
// paths that matter most: --dry-run (how agents preview before committing to a
|
||||
// spelling) and +batch-update (where two per-axis sub-ops both report success
|
||||
// while only the last axis stays frozen).
|
||||
func TestDimFreezeLegacyNote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
legacy := []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--count", "2"}
|
||||
modern := []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "2"}
|
||||
|
||||
t.Run("standalone dry-run carries the note", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
warning := dryRunWarning(t, DimFreeze, legacy)
|
||||
if !strings.Contains(warning, "equivalent to --rows 2") {
|
||||
t.Fatalf("dry-run warning = %q, want the exact replacement", warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("modern form stays silent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if w := dryRunWarning(t, DimFreeze, modern); w != "" {
|
||||
t.Fatalf("modern form must not warn, got %q", w)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch sub-op carries the note with its index", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
args := []string{"--url", testURL, "--operations",
|
||||
`[{"shortcut":"+cells-clear","input":{"sheet-id":"sh1","range":"A1:B2"}},` +
|
||||
`{"shortcut":"+dim-freeze","input":{"sheet-id":"sh1","dimension":"column","count":3}}]`}
|
||||
warning := dryRunWarning(t, BatchUpdate, args)
|
||||
if !strings.Contains(warning, "operations[1] (+dim-freeze)") || !strings.Contains(warning, "equivalent to --cols 3") {
|
||||
t.Fatalf("batch warning = %q, want the indexed note with the replacement", warning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch with only modern sub-ops stays silent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
args := []string{"--url", testURL, "--operations",
|
||||
`[{"shortcut":"+dim-freeze","input":{"sheet-id":"sh1","rows":1,"cols":2}}]`}
|
||||
if w := dryRunWarning(t, BatchUpdate, args); w != "" {
|
||||
t.Fatalf("modern sub-op must not warn, got %q", w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDimFreeze_FormValidation pins the two request forms as mutually
|
||||
// exclusive, and pins that neither-form is a prescriptive error rather than a
|
||||
// silent no-op.
|
||||
func TestDimFreeze_FormValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "forms cannot be mixed",
|
||||
args: []string{"--rows", "1", "--dimension", "row", "--count", "1"},
|
||||
want: "not both",
|
||||
},
|
||||
{
|
||||
name: "neither form given",
|
||||
args: []string{},
|
||||
want: "nothing to freeze",
|
||||
},
|
||||
{
|
||||
name: "negative rows",
|
||||
args: []string{"--rows", "-1"},
|
||||
want: "--rows must be >= 0",
|
||||
},
|
||||
{
|
||||
name: "count without dimension",
|
||||
args: []string{"--count", "2"},
|
||||
want: "--dimension is required alongside --count",
|
||||
},
|
||||
{
|
||||
name: "dimension without count",
|
||||
args: []string{"--dimension", "row"},
|
||||
want: "--count is required alongside --dimension",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
args := append([]string{"--url", testURL, "--sheet-id", testSheetID, "--dry-run"}, tt.args...)
|
||||
_, _, err := runShortcutCapturingErr(t, DimFreeze, args)
|
||||
requireValidation(t, err, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimMove_DryRun verifies the native v3 move_dimension payload shape.
|
||||
// CLI's --source-range "1:3" (1-based inclusive) is parsed into
|
||||
// source.{start_index=0, end_index=2} (0-based inclusive), and sheet_id is
|
||||
|
||||
296
shortcuts/sheets/lark_sheet_styles_put.go
Normal file
296
shortcuts/sheets/lark_sheet_styles_put.go
Normal file
@@ -0,0 +1,296 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── +styles-put ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Declarative visual spec for EXISTING spreadsheets. Eval attribution
|
||||
// showed ~73% of real +batch-update calls were pure formatting finishers
|
||||
// (style stamps + merges + resizes + freeze) hand-assembled as imperative
|
||||
// operations arrays — the top error surface. +styles-put replaces that
|
||||
// with the {styles:[...]} protocol already shared by +workbook-create /
|
||||
// +table-put --styles (identical vocabulary, parsed by the same
|
||||
// parseWorkbookCreateStyleItem), applied to a live workbook and expanded
|
||||
// client-side into ONE atomic batch_update.
|
||||
//
|
||||
// Per-sheet expansion order (server behavior verified live: style stamps
|
||||
// over merged regions are allowed — the top-left-only restriction applies
|
||||
// to value writes, not styles):
|
||||
//
|
||||
// cell_merges → cell_styles → row_sizes → col_sizes → freeze
|
||||
var StylesPut = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+styles-put",
|
||||
Description: "Apply one declarative visual spec (styles/merges/row-col sizes/freeze) to existing sheets in one batch request (fail-fast, no rollback).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+styles-put"),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +styles-put --url <URL> --styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:F1","font_weight":"bold"}],"freeze":{"rows":1}}]}'`,
|
||||
"Same --styles vocabulary as +workbook-create / +table-put; one item per target sheet, name = the real sheet name.",
|
||||
"Style stamps are safe to re-run; the whole spec goes out as one batch request — fail-fast, and applied sub-ops are NOT rolled back.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stylesPutOperations(runtime, token)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
ops, _ := stylesPutOperations(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ops, err := stylesPutOperations(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// stylesPutOperations parses --styles ({styles:[...]}, one item per target
|
||||
// sheet) and expands it into the MCP batch_update operations array. Reuses
|
||||
// the shared workbook-create style item parser, so field validation, alias
|
||||
// normalization (border "all" shorthand, style vocabulary) and the
|
||||
// aggregate-all-issues error shape are identical across the three --styles
|
||||
// carriers.
|
||||
func stylesPutOperations(runtime flagView, token string) ([]interface{}, error) {
|
||||
if strings.TrimSpace(runtime.Str("styles")) == "" {
|
||||
return nil, sheetsValidationForFlag("styles", "--styles is required")
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := parseWorkbookCreateStylesItems(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, sheetsValidationForFlag("styles", "--styles.styles must be a non-empty array (one item per target sheet)")
|
||||
}
|
||||
var probs []error
|
||||
type sheetSpec struct {
|
||||
name string
|
||||
payload *workbookCreateStylePayload
|
||||
}
|
||||
specs := make([]sheetSpec, 0, len(items))
|
||||
seenName := map[string]bool{}
|
||||
for i, item := range items {
|
||||
path := fmt.Sprintf("--styles.styles[%d]", i)
|
||||
name, _ := item["name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
probs = append(probs, common.ValidationErrorf("%s.name is required (the real sheet name; check +workbook-info)", path))
|
||||
continue
|
||||
}
|
||||
if seenName[name] {
|
||||
probs = append(probs, common.ValidationErrorf("%s.name %q appears twice; merge the two items", path, name))
|
||||
continue
|
||||
}
|
||||
seenName[name] = true
|
||||
payload, itemProbs := parseWorkbookCreateStyleItem(item, path)
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
}
|
||||
specs = append(specs, sheetSpec{name: name, payload: payload})
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ops := make([]interface{}, 0, len(specs)*4)
|
||||
var totalCells int64
|
||||
appendVisual := func(name string, op workbookCreateStyleOp) {
|
||||
input, toolName := workbookCreateVisualOpInput(token, "", name, op)
|
||||
if toolName == "" {
|
||||
return
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{"tool_name": toolName, "input": input})
|
||||
}
|
||||
for _, spec := range specs {
|
||||
// merges first so subsequent style stamps see the final grid.
|
||||
for _, m := range spec.payload.CellMerges {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "cell_merge", Range: m.Range, MergeType: m.MergeType})
|
||||
}
|
||||
for _, cs := range coalesceStyleStamps(spec.payload.CellStyles) {
|
||||
rows, cols, err := rangeDimensions(cs.Range)
|
||||
if err != nil {
|
||||
return nil, sheetsValidationForFlag("styles", "cell_styles range %q: %v", cs.Range, err)
|
||||
}
|
||||
if err := checkStampMatrixBudget("styles", cs.Range, rows, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalCells += int64(rows) * int64(cols)
|
||||
if err := checkBatchStampBudget("styles", totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": spec.name,
|
||||
"range": stripSheetPrefix(cs.Range),
|
||||
"cells": fillCellsMatrix(rows, cols, cs.Style),
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, rs := range spec.payload.RowSizes {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "row_size", Range: rs.Range, ResizeType: rs.ResizeType, Size: rs.Size})
|
||||
}
|
||||
for _, csz := range spec.payload.ColSizes {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "col_size", Range: csz.Range, ResizeType: csz.ResizeType, Size: csz.Size})
|
||||
}
|
||||
if f := spec.payload.Freeze; f != nil {
|
||||
appendVisual(spec.name, workbookCreateStyleOp{Kind: "freeze", FreezeRows: f.Rows, FreezeCols: f.Cols})
|
||||
}
|
||||
}
|
||||
if len(ops) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("styles",
|
||||
"--styles expands to %d operations even after merging adjacent same-style ranges, over the %d cap; split the spec into several +styles-put calls — and for alternating-row banding or value-dependent coloring use +cond-format-create instead of per-row stamps",
|
||||
len(ops), maxBatchOperations)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// coalesceStyleStamps merges cell_styles entries that carry the IDENTICAL
|
||||
// style into larger rectangles: same column span + contiguous/overlapping
|
||||
// rows fuse vertically, same row span + contiguous columns fuse
|
||||
// horizontally, iterated to a fixpoint. Models routinely emit one entry per
|
||||
// row (07-21 rerun: specs expanding to 184/203/861 operations against the
|
||||
// 100-op cap); a declarative spec describes intent, so execution shape is
|
||||
// the CLI's to optimize. Entries with unparsable ranges pass through
|
||||
// untouched (the per-op validation reports them with proper context).
|
||||
func coalesceStyleStamps(ops []workbookCreateCellStyleOp) []workbookCreateCellStyleOp {
|
||||
if len(ops) < 2 {
|
||||
return ops
|
||||
}
|
||||
type rect struct{ c1, r1, c2, r2 int }
|
||||
type entry struct {
|
||||
op workbookCreateCellStyleOp
|
||||
rc rect
|
||||
key string
|
||||
parsed bool
|
||||
alive bool
|
||||
}
|
||||
entries := make([]entry, len(ops))
|
||||
for i, op := range ops {
|
||||
e := entry{op: op, alive: true}
|
||||
c1, r1, c2, r2, err := workbookCreateStyleRangeBounds(op.Range)
|
||||
key, jerr := json.Marshal(op.Style) // map keys marshal sorted → canonical
|
||||
if err == nil && jerr == nil {
|
||||
e.rc, e.key, e.parsed = rect{c1, r1, c2, r2}, string(key), true
|
||||
}
|
||||
entries[i] = e
|
||||
}
|
||||
intersects := func(a, b rect) bool {
|
||||
return a.c1 <= b.c2 && b.c1 <= a.c2 && a.r1 <= b.r2 && b.r1 <= a.r2
|
||||
}
|
||||
// union returns the rectangle covering exactly a ∪ b, and whether the two
|
||||
// are mergeable at all: only same-column-span rows or same-row-span columns
|
||||
// that touch or overlap, so the union introduces no cell outside a ∪ b.
|
||||
union := func(a, b rect) (rect, bool) {
|
||||
switch {
|
||||
case a.c1 == b.c1 && a.c2 == b.c2 && b.r1 <= a.r2+1 && a.r1 <= b.r2+1:
|
||||
return rect{a.c1, min(a.r1, b.r1), a.c2, max(a.r2, b.r2)}, true
|
||||
case a.r1 == b.r1 && a.r2 == b.r2 && b.c1 <= a.c2+1 && a.c1 <= b.c2+1:
|
||||
return rect{min(a.c1, b.c1), a.r1, max(a.c2, b.c2), a.r2}, true
|
||||
}
|
||||
return rect{}, false
|
||||
}
|
||||
// Merging op j (later) into op i (earlier) moves j's write forward to i's
|
||||
// position, so it is only sound when nothing between them touches j's
|
||||
// cells — otherwise that intermediate op, which j used to overwrite, would
|
||||
// now land last and win. Style writes are field-wise last-write-wins
|
||||
// (mergeWorkbookCreateStyle), so silently reordering same-style stamps
|
||||
// around a differing one changes the final appearance.
|
||||
for i := range entries {
|
||||
if !entries[i].alive || !entries[i].parsed {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(entries); j++ {
|
||||
if !entries[j].alive || !entries[j].parsed || entries[j].key != entries[i].key {
|
||||
continue
|
||||
}
|
||||
merged, ok := union(entries[i].rc, entries[j].rc)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
safe := true
|
||||
for k := i + 1; k < j && safe; k++ {
|
||||
if !entries[k].alive {
|
||||
continue
|
||||
}
|
||||
// An unparsable range has unknown coverage: assume it collides.
|
||||
if !entries[k].parsed || intersects(entries[k].rc, entries[j].rc) {
|
||||
safe = false
|
||||
}
|
||||
}
|
||||
if !safe {
|
||||
continue
|
||||
}
|
||||
entries[i].rc = merged
|
||||
entries[j].alive = false
|
||||
j = i // rescan: the grown rectangle may now absorb earlier misses
|
||||
}
|
||||
}
|
||||
out := make([]workbookCreateCellStyleOp, 0, len(ops))
|
||||
for _, e := range entries {
|
||||
if !e.alive {
|
||||
continue
|
||||
}
|
||||
if !e.parsed {
|
||||
out = append(out, e.op)
|
||||
continue
|
||||
}
|
||||
out = append(out, workbookCreateCellStyleOp{
|
||||
Range: fmt.Sprintf("%s%d:%s%d",
|
||||
columnIndexToLetter(e.rc.c1), e.rc.r1+1,
|
||||
columnIndexToLetter(e.rc.c2), e.rc.r2+1),
|
||||
Style: e.op.Style,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stripSheetPrefix drops an optional "Sheet!"-style prefix from an A1 range:
|
||||
// the target sheet is already carried by the spec item's name, and the
|
||||
// batch sub-op input names the sheet separately.
|
||||
func stripSheetPrefix(rangeStr string) string {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
return strings.TrimSpace(rangeStr[idx+1:])
|
||||
}
|
||||
return strings.TrimSpace(rangeStr)
|
||||
}
|
||||
479
shortcuts/sheets/lark_sheet_styles_put_test.go
Normal file
479
shortcuts/sheets/lark_sheet_styles_put_test.go
Normal file
@@ -0,0 +1,479 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stylesPutView(spec map[string]interface{}) mapFlagView {
|
||||
return newMapFlagViewForCommand("+styles-put", map[string]interface{}{"styles": spec})
|
||||
}
|
||||
|
||||
// TestStylesPutOperations_ExpansionOrder pins the per-sheet expansion:
|
||||
// cell_merges → cell_styles → row_sizes → col_sizes → freeze, all inside one
|
||||
// batch_update operations array (server-side order dependence verified live).
|
||||
func TestStylesPutOperations_ExpansionOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "Sheet1",
|
||||
"cell_merges": []interface{}{map[string]interface{}{"range": "A5:A8"}},
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "A1:B1", "font_weight": "bold"}},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36)}},
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "A:B", "type": "pixel", "size": float64(120)}},
|
||||
"freeze": map[string]interface{}{"rows": float64(1), "cols": float64(2)},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
wantTools := []string{"merge_cells", "set_cell_range", "resize_range", "resize_range", "modify_sheet_structure"}
|
||||
if len(ops) != len(wantTools) {
|
||||
t.Fatalf("got %d ops, want %d", len(ops), len(wantTools))
|
||||
}
|
||||
for i, want := range wantTools {
|
||||
op := ops[i].(map[string]interface{})
|
||||
if op["tool_name"] != want {
|
||||
t.Fatalf("ops[%d].tool_name = %v, want %s", i, op["tool_name"], want)
|
||||
}
|
||||
input := op["input"].(map[string]interface{})
|
||||
if input["sheet_name"] != "Sheet1" {
|
||||
t.Fatalf("ops[%d] missing sheet_name: %v", i, input)
|
||||
}
|
||||
if input["excel_id"] != testToken {
|
||||
t.Fatalf("ops[%d] missing excel_id", i)
|
||||
}
|
||||
}
|
||||
// The style stamp carries a cells matrix matching the range (1×2).
|
||||
stamp := ops[1].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := stamp["cells"].([][]interface{})
|
||||
if len(cells) != 1 || len(cells[0]) != 2 {
|
||||
t.Fatalf("style stamp matrix = %dx%d, want 1x2", len(cells), len(cells[0]))
|
||||
}
|
||||
// Freeze rows and columns are combined into one operation because freeze is
|
||||
// full-state replacement server-side (verified 07-31 live): two per-axis
|
||||
// calls leave only the last axis frozen.
|
||||
freeze := ops[4].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if freeze["operation"] != "freeze" || freeze["freeze_rows"] != 1 || freeze["freeze_columns"] != 2 {
|
||||
t.Fatalf("freeze op = %v", freeze)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStylesPutOperations_Validation pins the aggregate error shape and the
|
||||
// section/name requirements.
|
||||
func TestStylesPutOperations_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing name and empty item aggregate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{
|
||||
map[string]interface{}{"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}}},
|
||||
map[string]interface{}{"name": "S2"},
|
||||
},
|
||||
}), testToken)
|
||||
ve := requireValidation(t, err, "name is required")
|
||||
if !strings.Contains(ve.Message, "at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze") {
|
||||
t.Fatalf("message %q missing empty-item issue", ve.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate sheet name rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}
|
||||
item2 := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(2)}}
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{item, item2},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "appears twice")
|
||||
})
|
||||
|
||||
t.Run("freeze-only item is valid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}},
|
||||
}), testToken)
|
||||
if err != nil || len(ops) != 1 {
|
||||
t.Fatalf("ops=%d err=%v", len(ops), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all-zero freeze rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(0)}}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "at least one dimension")
|
||||
})
|
||||
|
||||
t.Run("range prefixed with another sheet rejected", func(t *testing.T) {
|
||||
// Silently stripping "Detail!" would retarget the styles onto Summary.
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "Summary",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "Detail!A1:D1", "font_weight": "bold"}},
|
||||
}},
|
||||
}), testToken)
|
||||
ve := requireValidation(t, err, `names sheet "Detail" but the item targets "Summary"`)
|
||||
if !strings.Contains(ve.Message, "cell_styles") {
|
||||
t.Fatalf("message %q should locate the offending section", ve.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range prefixed with the item's own sheet passes and strips for every visual op", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "Summary",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "'Summary'!A1:D1", "font_weight": "bold"}},
|
||||
"cell_merges": []interface{}{map[string]interface{}{"range": "Summary!A2:B2"}, "'Summary'!C2:D2"},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "Summary!2:3", "type": "pixel", "size": float64(32)}},
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "'Summary'!A:C", "type": "pixel", "size": float64(120)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("matching prefix must stay accepted: %v", err)
|
||||
}
|
||||
gotRanges := []string{}
|
||||
for _, raw := range ops {
|
||||
input := raw.(map[string]interface{})["input"].(map[string]interface{})
|
||||
if rng, _ := input["range"].(string); rng != "" {
|
||||
gotRanges = append(gotRanges, rng)
|
||||
}
|
||||
}
|
||||
want := []string{"A2:B2", "C2:D2", "A1:D1", "2:3", "A:C"}
|
||||
if len(gotRanges) != len(want) {
|
||||
t.Fatalf("ranges = %v, want %v", gotRanges, want)
|
||||
}
|
||||
for i := range want {
|
||||
if gotRanges[i] != want[i] {
|
||||
t.Fatalf("ranges = %v, want %v", gotRanges, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown item key rejected with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}},
|
||||
"freezee": map[string]interface{}{"rows": float64(1)},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, `unknown key "freezee" — did you mean "freeze"`)
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesPayloadVocabularyForgiveness pins the 07-20 rerun fixes: the
|
||||
// payload path (--styles cell_styles objects) accepts the same habitual
|
||||
// vocabulary the flag path already normalized — border family folding, wrap
|
||||
// aliases, and enum VALUE canonicalization (CSS center → Lark middle etc.).
|
||||
func TestStylesPayloadVocabularyForgiveness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stamp := func(styleFields map[string]interface{}) ([]interface{}, error) {
|
||||
item := map[string]interface{}{"range": "A1:B1"}
|
||||
for k, v := range styleFields {
|
||||
item[k] = v
|
||||
}
|
||||
return stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_styles": []interface{}{item},
|
||||
}},
|
||||
}), testToken)
|
||||
}
|
||||
cellProto := func(t *testing.T, ops []interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
return cells[0][0].(map[string]interface{})
|
||||
}
|
||||
|
||||
t.Run("vertical_alignment center canonicalizes to middle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"vertical_alignment": "center", "font_weight": "BOLD"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
|
||||
if cs["vertical_alignment"] != "middle" || cs["font_weight"] != "bold" {
|
||||
t.Fatalf("cell_styles = %v, want middle/bold", cs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("off-enum value rejected client-side with did-you-mean", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stamp(map[string]interface{}{"vertical_alignment": "botom"})
|
||||
requireValidation(t, err, `did you mean "bottom"`)
|
||||
})
|
||||
|
||||
t.Run("boolean wrap_text folds to word_wrap auto-wrap", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"wrap_text": true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
|
||||
if cs["word_wrap"] != "auto-wrap" {
|
||||
t.Fatalf("word_wrap = %v, want auto-wrap", cs["word_wrap"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("borders object folds into border_styles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{
|
||||
"borders": map[string]interface{}{"style": "solid", "color": "#000000"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
top, _ := bs["top"].(map[string]interface{})
|
||||
if top == nil || top["style"] != "solid" {
|
||||
t.Fatalf("border_styles = %v, want all-sides solid", bs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flattened border_bottom and border_top_color fold per side", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{
|
||||
"border_bottom": map[string]interface{}{"style": "solid"},
|
||||
"border_top_color": "#FF0000",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
bottom, _ := bs["bottom"].(map[string]interface{})
|
||||
topSide, _ := bs["top"].(map[string]interface{})
|
||||
if bottom["style"] != "solid" || topSide["color"] != "#FF0000" {
|
||||
t.Fatalf("border_styles = %v", bs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("border_style thin means thin solid line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stamp(map[string]interface{}{"border_style": "thin"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
|
||||
top, _ := bs["top"].(map[string]interface{})
|
||||
if top["weight"] != "thin" || top["style"] != "solid" {
|
||||
t.Fatalf("border_styles.top = %v, want thin solid", top)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fore_color prescribes instead of guessing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stamp(map[string]interface{}{"fore_color": "#FF0000"})
|
||||
requireValidation(t, err, "fore_color is ambiguous")
|
||||
})
|
||||
|
||||
t.Run("bare string cell_merges accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_merges": []interface{}{"A5:B6"},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["range"] != "A5:B6" || input["merge_type"] != "all" {
|
||||
t.Fatalf("merge op = %v", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesResizeSizeAliases pins the one-way Excel-vocabulary aliases on
|
||||
// the shared styles resize parser: height in row_sizes / width in col_sizes
|
||||
// resolve to size silently; the wrong dimension's word is a targeted error.
|
||||
func TestStylesResizeSizeAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("height aliases to size in row_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "height": float64(36)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
block := input["resize_height"].(map[string]interface{})
|
||||
if block["value"] != 36 {
|
||||
t.Fatalf("resize_height = %v, want value 36", block)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("width aliases to size in col_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "A:C", "type": "pixel", "width": float64(120)}},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong-dimension word is a targeted error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "width": float64(36)}},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "does not apply to this array")
|
||||
})
|
||||
|
||||
t.Run("size plus alias together rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36), "height": float64(40)}},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, "either size or height")
|
||||
})
|
||||
}
|
||||
|
||||
// TestDimDeleteRangesOps pins the descending-order expansion and the
|
||||
// same-dimension / non-overlap guards.
|
||||
func TestDimDeleteRangesOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := func(ranges ...interface{}) mapFlagView {
|
||||
return newMapFlagViewForCommand("+dim-delete", map[string]interface{}{"ranges": ranges})
|
||||
}
|
||||
|
||||
t.Run("rows execute descending", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := dimDeleteRangesOps(view("5:5", "11:13", "8:8"), testToken, "", "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got []string
|
||||
for _, op := range ops {
|
||||
got = append(got, op.(map[string]interface{})["input"].(map[string]interface{})["range"].(string))
|
||||
}
|
||||
want := []string{"11:13", "8:8", "5:5"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed dimensions rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dimDeleteRangesOps(view("5:5", "C:C"), testToken, "", "S1")
|
||||
requireValidation(t, err, "rows OR columns")
|
||||
})
|
||||
|
||||
t.Run("overlap rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dimDeleteRangesOps(view("5:8", "7:9"), testToken, "", "S1")
|
||||
requireValidation(t, err, "overlap")
|
||||
})
|
||||
|
||||
t.Run("ranges cannot nest inside batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+dim-delete", map[string]interface{}{
|
||||
"sheet_name": "S1",
|
||||
"ranges": []interface{}{"5:5", "8:8"},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not supported inside +batch-update")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCoalesceStyleStamps_PreservesLastWriteWins pins the ordering contract of
|
||||
// the stamp optimizer: style writes are field-wise last-write-wins, so two
|
||||
// same-style stamps may only be merged when nothing between them touches the
|
||||
// cells whose write would move earlier. Grouping globally by style content
|
||||
// (the original implementation) turned red → blue → red into red → blue and
|
||||
// silently changed the final color.
|
||||
func TestCoalesceStyleStamps_PreservesLastWriteWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
red := map[string]interface{}{"background_color": "#FF0000"}
|
||||
blue := map[string]interface{}{"background_color": "#0000FF"}
|
||||
stamp := func(rng string, style map[string]interface{}) workbookCreateCellStyleOp {
|
||||
return workbookCreateCellStyleOp{Range: rng, Style: style}
|
||||
}
|
||||
lastStyleFor := func(ops []workbookCreateCellStyleOp, rng string) map[string]interface{} {
|
||||
var out map[string]interface{}
|
||||
for _, op := range ops {
|
||||
if op.Range == rng {
|
||||
out = op.Style
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("same cell red blue red keeps red last", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
|
||||
stamp("A1:A1", red), stamp("A1:A1", blue), stamp("A1:A1", red),
|
||||
})
|
||||
if last := lastStyleFor(got, "A1:A1"); last == nil || last["background_color"] != "#FF0000" {
|
||||
t.Fatalf("final style for A1 = %v, want the trailing red; ops=%+v", last, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("intervening overlapping stamp blocks the merge", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// bold A1:B1, italic on B1, bold B1 again: merging the two bolds would
|
||||
// hoist B1's bold ahead of the italic and lose the italic.
|
||||
bold := map[string]interface{}{"font_weight": "bold"}
|
||||
italic := map[string]interface{}{"font_style": "italic"}
|
||||
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
|
||||
stamp("A1:A1", bold), stamp("A1:A1", italic), stamp("A1:A1", bold),
|
||||
})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("overlapping intermediate stamp must prevent merging, got %d ops: %+v", len(got), got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adjacent same-style runs still coalesce", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
bold := map[string]interface{}{"font_weight": "bold"}
|
||||
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
|
||||
stamp("A1:A1", bold), stamp("A2:A2", bold), stamp("A3:A3", bold),
|
||||
})
|
||||
if len(got) != 1 || got[0].Range != "A1:A3" {
|
||||
t.Fatalf("adjacent same-style stamps should merge into A1:A3, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disjoint intermediate stamp does not block the merge", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
bold := map[string]interface{}{"font_weight": "bold"}
|
||||
italic := map[string]interface{}{"font_style": "italic"}
|
||||
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
|
||||
stamp("A1:A1", bold), stamp("Z9:Z9", italic), stamp("A2:A2", bold),
|
||||
})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("disjoint intermediate stamp should still allow merging, got %+v", got)
|
||||
}
|
||||
if got[0].Range != "A1:A2" {
|
||||
t.Fatalf("bold stamps should merge to A1:A2, got %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -88,6 +88,7 @@ var TablePut = common.Shortcut{
|
||||
return tablePutWrite(ctx, runtime, token, payload, styles)
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +table-put --url <URL> --sheets '{"sheets":[{"name":"S1","columns":["City","Rev"],"dtypes":{"Rev":"float64"},"data":[["SH",1234.5]]}]}'`,
|
||||
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
|
||||
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
|
||||
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
|
||||
@@ -241,6 +242,11 @@ func decoderExpectEOF(dec *json.Decoder) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tablePutSheetsSkeleton is the one-line --sheets shape inlined on a decode
|
||||
// error, so the retry needs no --print-schema round trip. Field vocabulary
|
||||
// mirrors tableSheetIn.
|
||||
const tablePutSheetsSkeleton = `{"sheets":[{"name":"Sheet1","columns":["City","Revenue"],"dtypes":{"Revenue":"float64"},"data":[["SH",123.4],["BJ",56.7]],"start_cell":"A1"}]}`
|
||||
|
||||
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
|
||||
// validated payload. UseNumber keeps numeric cells as json.Number so large
|
||||
// integers (order IDs, etc.) survive without precision loss or scientific
|
||||
@@ -259,7 +265,29 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
|
||||
Sheets []tableSheetIn `json:"sheets"`
|
||||
}
|
||||
if err := dec.Decode(&wire); err != nil {
|
||||
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
// Eval traces show two distinct decode failures that each burned
|
||||
// retries: a field with the wrong JSON kind (columns as objects,
|
||||
// dtypes as an array) — fixed by seeing the expected shape once —
|
||||
// and shell-mangled JSON, fixed by moving the payload to stdin/@file.
|
||||
verr := common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
var ute *json.UnmarshalTypeError
|
||||
if errors.As(err, &ute) {
|
||||
// A mismatch with no field path is the missing envelope: the
|
||||
// payload IS the sub-sheet list, written without the wrapper.
|
||||
// Say that in the message — the Go unmarshal text ("cannot
|
||||
// unmarshal array into Go value of type struct { Sheets …}")
|
||||
// names the internal type, not the fix.
|
||||
if ute.Field == "" {
|
||||
verr = common.ValidationErrorf(
|
||||
`--sheets: top level must be the object {"sheets":[…]}, got a bare JSON %s; wrap the sub-sheet list in a "sheets" key`,
|
||||
ute.Value).WithCause(err)
|
||||
}
|
||||
return nil, verr.WithHint(
|
||||
"expected shape: %s (columns is a flat string array; dtypes/formats are column-name-keyed maps; data is row-major)",
|
||||
tablePutSheetsSkeleton)
|
||||
}
|
||||
return nil, verr.WithHint(
|
||||
"if the payload contains formulas / quotes / commas, pass it via stdin (`--sheets - < file`) or a relative @file (`--sheets @./payload.json`)")
|
||||
}
|
||||
// Reject trailing non-whitespace after the first JSON value: json.Decoder
|
||||
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
|
||||
@@ -1178,6 +1206,12 @@ var TableGet = common.Shortcut{
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token, "ranges": []string{rng},
|
||||
"include_styles": true, "value_render_option": "raw_value",
|
||||
"cell_limit": unboundedReadLimit,
|
||||
}
|
||||
// Execute adds these caps too; echoing them here keeps dry-run and the
|
||||
// real request the same shape, so validating one tells you about the other.
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
sheetSelectorForToolInput(input,
|
||||
strings.TrimSpace(runtime.Str("sheet-id")),
|
||||
@@ -1201,15 +1235,37 @@ var TableGet = common.Shortcut{
|
||||
noHeader := runtime.Bool("no-header")
|
||||
userRange := strings.TrimSpace(runtime.Str("range"))
|
||||
sheets := make([]interface{}, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
spec, err := readSheetAsSpec(ctx, runtime, token, t, userRange, noHeader)
|
||||
// The char cap is a memory guard, so it must bound the WHOLE read, not
|
||||
// each sheet independently: a 30-sheet workbook would otherwise be
|
||||
// allowed 30× the cap. Track what previous sheets consumed and hand the
|
||||
// remainder to the next one; when it runs out, stop and name the sheets
|
||||
// left unread instead of silently returning a short workbook.
|
||||
budget := maxCharsBudget(runtime)
|
||||
var unread []string
|
||||
for i, t := range targets {
|
||||
remaining := 0
|
||||
if budget > 0 {
|
||||
remaining = budget - consumedChars(sheets)
|
||||
if remaining <= 0 {
|
||||
for _, rest := range targets[i:] {
|
||||
unread = append(unread, rest.name)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
spec, err := readSheetAsSpec(ctx, runtime, token, t, userRange, noHeader, remaining)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheets = append(sheets, spec)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
|
||||
return nil
|
||||
payload := map[string]interface{}{"sheets": sheets}
|
||||
if len(unread) > 0 {
|
||||
payload["truncated"] = true
|
||||
payload["unread_sheets"] = unread
|
||||
payload["truncation_warning"] = fmt.Sprintf("the %d-char read budget was exhausted before %d sheet(s) were read (%s); re-run per sheet with --sheet-name, or raise --max-chars", budget, len(unread), strings.Join(unread, ", "))
|
||||
}
|
||||
return emitReadResult(runtime, payload)
|
||||
},
|
||||
Tips: []string{
|
||||
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
|
||||
@@ -1326,7 +1382,7 @@ func tableGetSheetMeta(r interface{}) (id, name string, rowCount, colCount int)
|
||||
// a single `astype()` call covers every column); `formats` is emitted only for
|
||||
// columns whose source cells carry a non-empty number_format, since `astype`
|
||||
// ignores it and we'd rather not pollute the output.
|
||||
func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool) (map[string]interface{}, error) {
|
||||
func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool, charBudget int) (map[string]interface{}, error) {
|
||||
emptySpec := func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": t.name,
|
||||
@@ -1354,14 +1410,35 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
"value_render_option": "raw_value",
|
||||
"cell_limit": unboundedReadLimit,
|
||||
}
|
||||
// --max-chars binds the char budget (default 500000); --output-path raises
|
||||
// it to the bounded offload default. Without this the tool applied its own
|
||||
// ~50000 default and silently dropped rows past it with no signal in the
|
||||
// +table-get output. charBudget > 0 caps this sheet by what the whole-
|
||||
// workbook read has left, so a multi-sheet workbook cannot consume the
|
||||
// per-sheet cap N times over.
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
if charBudget > 0 && charBudget < n {
|
||||
n = charBudget
|
||||
}
|
||||
input["max_chars"] = n
|
||||
}
|
||||
sheetSelectorForToolInput(input, t.id, t.name)
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
truncated := cellRangesTruncated(out)
|
||||
grid := extractCellGrid(out)
|
||||
if len(grid) == 0 {
|
||||
return emptySpec(), nil
|
||||
// An empty grid can itself be the result of clipping (the cap was spent
|
||||
// before any row came back), so the truncation flag must survive here —
|
||||
// dropping it reports a partial read as a complete empty sheet.
|
||||
spec := emptySpec()
|
||||
if truncated {
|
||||
spec["truncated"] = true
|
||||
spec["truncation_warning"] = "the read hit the char cap before any row was returned for this sheet; raise --max-chars or read a narrower --range"
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
var headerRow []map[string]interface{}
|
||||
@@ -1433,9 +1510,38 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
if len(formats) > 0 {
|
||||
spec["formats"] = formats
|
||||
}
|
||||
// The tool clipped the read at max_chars: rows past the cap are missing from
|
||||
// data. Surface it so the caller doesn't mistake a partial read for the whole
|
||||
// sheet — re-run with --output-path (unlimited) or a higher --max-chars.
|
||||
if truncated {
|
||||
spec["truncated"] = true
|
||||
spec["truncation_warning"] = "Result truncated by max_chars; rows past the cap were not returned. Best: re-run with --output-path to dump the sheet to a file under the much larger offload cap. Alternatively raise --max-chars, or continue-read the remaining rows by passing --range for them — but that needs --no-header and you must reattach the header row and reconcile per-chunk dtypes yourself (this chunk's types were inferred from the rows returned here)."
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// cellRangesTruncated reports whether a get_cell_ranges response was clipped by
|
||||
// max_chars — either the top-level has_more flag or the first range's truncated
|
||||
// flag. Used by +table-get, whose spec output otherwise drops both signals.
|
||||
func cellRangesTruncated(out interface{}) bool {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if hm, ok := m["has_more"].(bool); ok && hm {
|
||||
return true
|
||||
}
|
||||
ranges, _ := m["ranges"].([]interface{})
|
||||
if len(ranges) > 0 {
|
||||
if r0, ok := ranges[0].(map[string]interface{}); ok {
|
||||
if t, ok := r0["truncated"].(bool); ok {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
|
||||
// or "" for an empty sheet.
|
||||
//
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -1687,3 +1688,66 @@ func TestValidColumnType_AcceptsEmpty(t *testing.T) {
|
||||
t.Error(`validColumnType("float") = true, want false`)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTableGet_CharBudgetSpansTheWholeWorkbook pins that --max-chars bounds the
|
||||
// WHOLE multi-sheet read, not each sheet independently.
|
||||
//
|
||||
// The cap is a memory guard on a non-streaming path, so letting every sheet
|
||||
// spend it in full would let a 30-sheet workbook pull 30x what the caller
|
||||
// allowed — quietly, since each individual request looks compliant. The clamp
|
||||
// that prevents it (charBudget in readSheetAsSpec) was unpinned: removing it
|
||||
// passed the entire suite, because the outer loop's exhaustion check is a
|
||||
// separate mechanism and keeps working.
|
||||
//
|
||||
// Asserted on the wire: sheet 2's request must ask for less than sheet 1's.
|
||||
func TestTableGet_CharBudgetSpansTheWholeWorkbook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const budget = 40000
|
||||
structure := toolOutputStub(testToken, "read", `{"sheets":[`+
|
||||
`{"sheet_id":"sh1","sheet_name":"S1","row_count":50,"column_count":3,"index":0},`+
|
||||
`{"sheet_id":"sh2","sheet_name":"S2","row_count":50,"column_count":3,"index":1}`+
|
||||
`]}`)
|
||||
|
||||
// One reusable stub answers both the current-region probes and the cell
|
||||
// reads; every captured body is inspected below.
|
||||
payload := `{"current_region":"A1:B2","ranges":[{"cells":[` +
|
||||
`[{"value":"col1"},{"value":"col2"}],` +
|
||||
`[{"value":"a"},{"value":"b"}]` +
|
||||
`]}]}`
|
||||
reads := toolOutputStub(testToken, "read", payload)
|
||||
reads.Reusable = true
|
||||
|
||||
out, err := runShortcutWithStubs(t, TableGet,
|
||||
[]string{"--url", testURL, "--max-chars", strconv.Itoa(budget)}, structure, reads)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
|
||||
var caps []int
|
||||
for _, body := range reads.CapturedBodies {
|
||||
var wire struct {
|
||||
ToolName string `json:"tool_name"`
|
||||
Input string `json:"input"`
|
||||
}
|
||||
if json.Unmarshal(body, &wire) != nil || wire.ToolName != "get_cell_ranges" {
|
||||
continue
|
||||
}
|
||||
var input struct {
|
||||
MaxChars int `json:"max_chars"`
|
||||
}
|
||||
if json.Unmarshal([]byte(wire.Input), &input) != nil || input.MaxChars == 0 {
|
||||
continue
|
||||
}
|
||||
caps = append(caps, input.MaxChars)
|
||||
}
|
||||
if len(caps) < 2 {
|
||||
t.Fatalf("want a cell read per sheet, captured caps = %v", caps)
|
||||
}
|
||||
if caps[0] > budget {
|
||||
t.Errorf("first sheet asked for max_chars=%d, over the %d budget", caps[0], budget)
|
||||
}
|
||||
if caps[1] >= caps[0] {
|
||||
t.Errorf("second sheet asked for max_chars=%d, not reduced by what the first consumed (%d) — the budget is per-workbook, not per-sheet", caps[1], caps[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/drive"
|
||||
@@ -405,7 +407,11 @@ var SheetCopy = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+sheet-copy"),
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
Tips: []string{
|
||||
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
|
||||
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
|
||||
},
|
||||
Validate: validateViaInput(sheetCopyInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -914,6 +920,18 @@ type workbookCreateStylePayload struct {
|
||||
RowSizes []workbookCreateResizeOp
|
||||
ColSizes []workbookCreateResizeOp
|
||||
CellMerges []workbookCreateMergeOp
|
||||
Freeze *workbookCreateFreezeOp
|
||||
}
|
||||
|
||||
// workbookCreateFreezeOp freezes the first Rows rows / Cols columns.
|
||||
// Zero means "that axis ends up UNFROZEN", not "leave it alone": freeze is
|
||||
// full-state replacement server-side (see workbookCreateVisualOpInput's freeze
|
||||
// branch), so a declarative spec that omits an axis is stating it should not be
|
||||
// frozen. parseWorkbookCreateFreezeOp rejects an all-zero op, so at least one
|
||||
// axis is always positive here.
|
||||
type workbookCreateFreezeOp struct {
|
||||
Rows int
|
||||
Cols int
|
||||
}
|
||||
|
||||
type workbookCreateCellStyleOp struct {
|
||||
@@ -965,7 +983,11 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
|
||||
if len(items) != 1 {
|
||||
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
|
||||
}
|
||||
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
|
||||
@@ -988,21 +1010,28 @@ func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*w
|
||||
}
|
||||
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
|
||||
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
|
||||
var probs []error
|
||||
for i, item := range items {
|
||||
name, _ := item["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
|
||||
continue
|
||||
}
|
||||
if name != payload.Sheets[i].Name {
|
||||
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
|
||||
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
|
||||
continue
|
||||
}
|
||||
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
|
||||
if len(itemProbs) > 0 {
|
||||
probs = append(probs, itemProbs...)
|
||||
continue
|
||||
}
|
||||
out.ByIndex[i] = style
|
||||
out.ByName[name] = style
|
||||
}
|
||||
if err := joinStyleValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1030,182 +1059,468 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
|
||||
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
|
||||
// are validated even after one fails, and every issue is returned in the
|
||||
// slice: eval traces show agents fixing --styles errors one round trip per
|
||||
// error (border side, then row_sizes.type, then size…) because only the
|
||||
// first was ever reported.
|
||||
// workbookCreateStyleItemKeys is the full top-level vocabulary of one
|
||||
// --styles item, shared by the three carriers (+workbook-create /
|
||||
// +table-put / +styles-put).
|
||||
var workbookCreateStyleItemKeys = []string{"name", "cell_styles", "row_sizes", "col_sizes", "cell_merges", "freeze"}
|
||||
|
||||
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, []error) {
|
||||
payload := &workbookCreateStylePayload{}
|
||||
var err error
|
||||
if raw, ok := item["cell_styles"]; ok {
|
||||
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var probs []error
|
||||
// Reject unknown top-level keys first: a typo like "freezee" would
|
||||
// otherwise be silently dropped while the rest of the item applies.
|
||||
var unknown []string
|
||||
for k := range item {
|
||||
known := false
|
||||
for _, lk := range workbookCreateStyleItemKeys {
|
||||
if k == lk {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
for _, k := range unknown {
|
||||
msg := fmt.Sprintf("%s has unknown key %q", path, k)
|
||||
if match := suggest.Closest(strings.ToLower(k), workbookCreateStyleItemKeys, 1); len(match) > 0 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
probs = append(probs, common.ValidationErrorf("%s", msg))
|
||||
}
|
||||
// Normalize "Sheet!" range prefixes before the section parsers see them:
|
||||
// the target sheet is named by the item (or, on +workbook-create --values,
|
||||
// by the single sheet being created), so a prefix is at best redundant and
|
||||
// at worst a silent retarget. Stripping is unconditional — an item without
|
||||
// a name (the --values path, where name is optional) must not be left with
|
||||
// prefixed ranges the section parsers then reject as malformed. Only the
|
||||
// "names a DIFFERENT sheet" report needs a name to compare against, so it
|
||||
// is skipped when there is none.
|
||||
name, _ := item["name"].(string)
|
||||
probs = append(probs, normalizeStyleItemRangePrefixes(item, path, strings.TrimSpace(name))...)
|
||||
if raw, ok := item["cell_styles"]; ok {
|
||||
var errsHere []error
|
||||
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["row_sizes"]; ok {
|
||||
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["col_sizes"]; ok {
|
||||
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errsHere []error
|
||||
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["cell_merges"]; ok {
|
||||
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
var errsHere []error
|
||||
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
|
||||
probs = append(probs, errsHere...)
|
||||
}
|
||||
if raw, ok := item["freeze"]; ok {
|
||||
freeze, err := parseWorkbookCreateFreezeOp(raw, path+".freeze")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
} else {
|
||||
payload.Freeze = freeze
|
||||
}
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
|
||||
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
|
||||
if len(probs) > 0 {
|
||||
return nil, probs
|
||||
}
|
||||
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 && payload.Freeze == nil {
|
||||
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze", path)}
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
|
||||
// styleItemRangeSections are the --styles item sections whose entries carry an
|
||||
// A1 range that may be written with a redundant "Sheet!" prefix.
|
||||
var styleItemRangeSections = []string{"cell_styles", "row_sizes", "col_sizes", "cell_merges"}
|
||||
|
||||
// normalizeStyleItemRangePrefixes strips an optional "Sheet!" prefix from every
|
||||
// range in one --styles item, in place, and reports the ones naming a sheet
|
||||
// other than the item's own.
|
||||
//
|
||||
// Stripping has to happen before the section parsers run: parseWorkbookCreateResizeOp
|
||||
// feeds the range straight to parseA1Range, so row_sizes like "Sheet1!2:3" fail
|
||||
// as malformed even though the intent is unambiguous — the target sheet is
|
||||
// already carried by the item name and by each expanded sub-op's sheet selector.
|
||||
// A prefix naming a DIFFERENT sheet is an error rather than a strip, because
|
||||
// stripping alone would silently retarget the operation onto the item's sheet
|
||||
// (name "Summary" + range "Detail!A1:D1" applying to Summary). It is stripped
|
||||
// anyway so the section parser reports the entry's own issues instead of piling
|
||||
// a redundant syntax error on top of the mismatch.
|
||||
//
|
||||
// name is "" on +workbook-create --values, whose single styles item needs no
|
||||
// name (the workbook has exactly one sheet, still unnamed at spec time). There
|
||||
// is then no sheet to disagree with, so ranges are stripped without the
|
||||
// mismatch report — stripping still has to happen, or the section parsers see
|
||||
// a prefixed range and reject it as malformed.
|
||||
func normalizeStyleItemRangePrefixes(item map[string]interface{}, path, name string) []error {
|
||||
var probs []error
|
||||
rewrite := func(section, rangeStr string) (string, bool) {
|
||||
idx := strings.Index(rangeStr, "!")
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
prefix := strings.Trim(strings.TrimSpace(rangeStr[:idx]), "'")
|
||||
if name != "" && prefix != name {
|
||||
probs = append(probs, common.ValidationErrorf(
|
||||
"%s.%s range %q names sheet %q but the item targets %q — drop the prefix, or move the entry into the item for %q",
|
||||
path, section, rangeStr, prefix, name, prefix))
|
||||
}
|
||||
return strings.TrimSpace(rangeStr[idx+1:]), true
|
||||
}
|
||||
for _, key := range styleItemRangeSections {
|
||||
arr, ok := item[key].([]interface{})
|
||||
if !ok {
|
||||
continue // a wrong-shaped section is the section parser's to report.
|
||||
}
|
||||
for i, elem := range arr {
|
||||
section := fmt.Sprintf("%s[%d]", key, i)
|
||||
switch v := elem.(type) {
|
||||
case map[string]interface{}:
|
||||
rangeStr, ok := v["range"].(string)
|
||||
if !ok {
|
||||
continue // non-string/missing range: the section parser reports it.
|
||||
}
|
||||
if stripped, changed := rewrite(section, rangeStr); changed {
|
||||
v["range"] = stripped
|
||||
}
|
||||
case string:
|
||||
// cell_merges also accepts a bare range string.
|
||||
if key != "cell_merges" {
|
||||
continue
|
||||
}
|
||||
if stripped, changed := rewrite(section, v); changed {
|
||||
arr[i] = stripped
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return probs
|
||||
}
|
||||
|
||||
// parseWorkbookCreateFreezeOp parses a {rows, cols} freeze section. At least
|
||||
// one dimension must be positive — an all-zero freeze is a no-op the caller
|
||||
// almost certainly didn't mean.
|
||||
func parseWorkbookCreateFreezeOp(raw interface{}, path string) (*workbookCreateFreezeOp, error) {
|
||||
obj, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an object like {\"rows\":1} or {\"rows\":1,\"cols\":2}", path)
|
||||
}
|
||||
// "cols" and "columns" are aliases for the same field, so accepting both in
|
||||
// one object would make the result depend on Go's randomized map iteration
|
||||
// order — the same payload could freeze 1 column on one run and 2 on the
|
||||
// next. Reject the conflict instead of silently picking a winner.
|
||||
if _, hasCols := obj["cols"]; hasCols {
|
||||
if _, hasColumns := obj["columns"]; hasColumns {
|
||||
if !jsonEqual(obj["cols"], obj["columns"]) {
|
||||
return nil, common.ValidationErrorf("%s got conflicting values for \"cols\" and \"columns\" (aliases of the same field) — keep one", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := &workbookCreateFreezeOp{}
|
||||
// Iterate deterministically so error reporting is stable across runs too.
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
v := obj[k]
|
||||
n, isNum := v.(float64)
|
||||
if !isNum || n != float64(int(n)) || n < 0 {
|
||||
return nil, common.ValidationErrorf("%s.%s must be a non-negative integer", path, k)
|
||||
}
|
||||
switch k {
|
||||
case "rows":
|
||||
out.Rows = int(n)
|
||||
case "cols", "columns":
|
||||
out.Cols = int(n)
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s.%s is not a supported field (want rows/cols)", path, k)
|
||||
}
|
||||
}
|
||||
if out.Rows == 0 && out.Cols == 0 {
|
||||
return nil, common.ValidationErrorf("%s must freeze at least one dimension (rows or cols > 0)", path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// joinStyleValidationErrors folds the issues collected across one --styles
|
||||
// parse into a single typed error that lists them all, so the caller can fix
|
||||
// the whole payload in one retry instead of one error per round trip.
|
||||
func joinStyleValidationErrors(probs []error) error {
|
||||
switch len(probs) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
// Re-attribute to the outer flag even for a single issue: the inner
|
||||
// error is scoped to a nested path and carries no Param, so an agent
|
||||
// would have to parse prose to learn which flag to fix. Message text
|
||||
// is preserved; only the typed attribution is added — and the inner
|
||||
// hint rides along, since a lone issue has the outer Hint slot free.
|
||||
msg, hint := aggregatedIssueParts(probs[0])
|
||||
verr := sheetsValidationForFlag("styles", "%s", msg).WithCause(probs[0])
|
||||
if hint != "" {
|
||||
verr = verr.WithHint("%s", hint)
|
||||
}
|
||||
return verr
|
||||
}
|
||||
const maxShown = 8
|
||||
msgs := make([]string, 0, len(probs))
|
||||
for _, e := range probs {
|
||||
msgs = append(msgs, aggregatedIssueText(e))
|
||||
}
|
||||
suffix := ""
|
||||
if len(msgs) > maxShown {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
|
||||
msgs = msgs[:maxShown]
|
||||
}
|
||||
return sheetsValidationForFlag("styles", "--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix).
|
||||
WithCause(probs[0])
|
||||
}
|
||||
|
||||
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
|
||||
}
|
||||
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, nil
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
|
||||
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateCellStyleOp{}, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||||
}
|
||||
styleObj := make(map[string]interface{}, len(op)-1)
|
||||
for k, v := range op {
|
||||
if k == "range" {
|
||||
continue
|
||||
}
|
||||
styleObj[k] = v
|
||||
}
|
||||
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
|
||||
if err != nil {
|
||||
return workbookCreateCellStyleOp{}, err
|
||||
}
|
||||
if len(style) == 0 {
|
||||
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
|
||||
}
|
||||
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
|
||||
}
|
||||
|
||||
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateMergeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
|
||||
}
|
||||
mergeType = strings.TrimSpace(v)
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, nil
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
|
||||
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
|
||||
// A bare range string means {range: s, merge_type: all} — the only
|
||||
// possible reading (07-20 eval hit).
|
||||
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
|
||||
raw = map[string]interface{}{"range": strings.TrimSpace(s)}
|
||||
}
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateMergeOp{}, err
|
||||
}
|
||||
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
|
||||
}
|
||||
mergeType := "all"
|
||||
if raw, ok := op["merge_type"]; ok {
|
||||
v, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
|
||||
}
|
||||
mergeType = normalizeMergeType(strings.TrimSpace(v))
|
||||
}
|
||||
switch mergeType {
|
||||
case "all", "rows", "columns":
|
||||
default:
|
||||
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
|
||||
return workbookCreateMergeOp{}, err
|
||||
}
|
||||
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
|
||||
}
|
||||
|
||||
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
|
||||
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
|
||||
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
|
||||
// the caller's enum check to reject.
|
||||
func normalizeMergeType(v string) string {
|
||||
lower := strings.ToLower(v)
|
||||
lower = strings.TrimPrefix(lower, "merge_")
|
||||
switch lower {
|
||||
case "all", "rows", "columns":
|
||||
return lower
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s must be an array", path)
|
||||
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
|
||||
}
|
||||
ops := make([]workbookCreateResizeOp, 0, len(arr))
|
||||
var probs []error
|
||||
for i, raw := range arr {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
|
||||
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
probs = append(probs, err)
|
||||
continue
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
|
||||
ops = append(ops, op)
|
||||
}
|
||||
return ops, probs
|
||||
}
|
||||
|
||||
// resizeOpExample renders a complete valid op for the dimension, inlined on
|
||||
// every type/size error: eval traces show the field errors chaining (type
|
||||
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
|
||||
// trip, because no error ever showed a whole valid op at once.
|
||||
func resizeOpExample(dimension string) string {
|
||||
if dimension == "column" {
|
||||
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
|
||||
}
|
||||
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
|
||||
}
|
||||
|
||||
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
|
||||
}
|
||||
rangeStr, err := requireWorkbookCreateRange(op, path)
|
||||
if err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
|
||||
}
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType == "" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers like 2:10"
|
||||
if dimension == "column" {
|
||||
want = "column letters like A:E"
|
||||
}
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
|
||||
}
|
||||
typeHint := "pixel/standard"
|
||||
if dimension == "row" {
|
||||
typeHint = "pixel/standard/auto"
|
||||
}
|
||||
resizeType, _ := op["type"].(string)
|
||||
resizeType = strings.TrimSpace(resizeType)
|
||||
if resizeType != "" {
|
||||
if dimension == "column" && resizeType == "auto" {
|
||||
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
|
||||
}
|
||||
switch resizeType {
|
||||
case "pixel", "standard", "auto":
|
||||
default:
|
||||
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
size := 0
|
||||
if raw, ok := op["size"]; ok {
|
||||
n, ok := util.ToFloat64(raw)
|
||||
if !ok || n <= 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
|
||||
}
|
||||
return ops, nil
|
||||
// size is the canonical dimension key (uniform across row_sizes and
|
||||
// col_sizes — the array name already carries the dimension). The Excel-
|
||||
// vocabulary alias (height on rows, width on columns) is accepted
|
||||
// silently; the WRONG dimension's word is a targeted error, never a
|
||||
// silent rewrite.
|
||||
alias, wrongDim := "height", "width"
|
||||
if dimension == "column" {
|
||||
alias, wrongDim = "width", "height"
|
||||
}
|
||||
if _, has := op[wrongDim]; has {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.%s does not apply to this array (the array name carries the dimension); use size, e.g. %s", path, wrongDim, resizeOpExample(dimension))
|
||||
}
|
||||
sizeRaw, hasSize := op["size"]
|
||||
if aliasRaw, hasAlias := op[alias]; hasAlias {
|
||||
if hasSize {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s: give either size or %s, not both", path, alias)
|
||||
}
|
||||
sizeRaw, hasSize = aliasRaw, true
|
||||
}
|
||||
size := 0
|
||||
if hasSize {
|
||||
n, ok := util.ToFloat64(sizeRaw)
|
||||
if !ok || n <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
|
||||
}
|
||||
size = int(n)
|
||||
}
|
||||
// type is optional ceremony when a pixel size is given: {range, size}
|
||||
// means a pixel resize, exactly as --width/--height without --type does
|
||||
// on the flag path. Explicit standard/auto still needs type.
|
||||
if resizeType == "" {
|
||||
if size <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s needs size (px) or type (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
|
||||
}
|
||||
resizeType = "pixel"
|
||||
}
|
||||
if resizeType == "pixel" && size <= 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
|
||||
}
|
||||
if resizeType != "pixel" && size > 0 {
|
||||
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
|
||||
}
|
||||
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size", alias); err != nil {
|
||||
return workbookCreateResizeOp{}, err
|
||||
}
|
||||
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
|
||||
}
|
||||
|
||||
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
|
||||
@@ -1245,6 +1560,9 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := foldBorderFamilyAliases(in, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeCellStyleAliases(in, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1259,15 +1577,33 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
if !ok {
|
||||
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
|
||||
}
|
||||
expandBorderAllShorthand(m)
|
||||
if err := validateWorkbookBorderStyles(m, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["border_styles"] = m
|
||||
case "value", "formula", "rich_text", "multiple_values", "note", "data_validation":
|
||||
return nil, common.ValidationErrorf("%s is for styles only; put content in --values or use --sheets for typed cell objects", path)
|
||||
return nil, common.ValidationErrorf("%s.%s is a content field — a styles spec carries no cell content; write values/formulas via +cells-set or +table-put", path, k)
|
||||
default:
|
||||
if !workbookCreateCellStyleField(k) {
|
||||
return nil, common.ValidationErrorf("%s.%s is not a supported style field", path, k)
|
||||
// Universal rejection with the full field list: this is the
|
||||
// mechanism that absorbs the infinite tail of spelling
|
||||
// permutations at a fixed one-retry cost — silent aliases are
|
||||
// reserved for high-frequency words from real external
|
||||
// vocabularies (see the style_vocab.go contract). A curated
|
||||
// prescription wins over did-you-mean; without one, the
|
||||
// distance match must be a near-typo (≤2 edits) — a
|
||||
// concept-swap neighbor (font_bold → font_color, distance 3)
|
||||
// misleads worse than silence.
|
||||
msg := fmt.Sprintf("%s.%s is not a supported style field", path, k)
|
||||
lower := strings.ToLower(k)
|
||||
if rx, ok := styleFieldPrescriptions[lower]; ok {
|
||||
msg += " — " + rx
|
||||
} else if match := suggest.Closest(lower, workbookCreateCellStyleFieldList, 1); len(match) > 0 && suggest.Levenshtein(lower, match[0]) <= 2 {
|
||||
msg += fmt.Sprintf(" — did you mean %q?", match[0])
|
||||
}
|
||||
msg += "; supported: " + strings.Join(workbookCreateCellStyleFieldList, ", ")
|
||||
return nil, common.ValidationErrorf("%s", msg)
|
||||
}
|
||||
cellStyle[k] = v
|
||||
}
|
||||
@@ -1278,6 +1614,19 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// workbookCreateCellStyleFieldList is what a caller may WRITE in a cell_styles
|
||||
// item, in display order for the unknown-field hint — the canonical scalar
|
||||
// vocabulary (workbookCreateCellStyleField) plus the two border carriers.
|
||||
// "border" is the documented four-sides shorthand rather than a field the
|
||||
// switch above ever sees: foldBorderFamilyAliases folds it into border_styles
|
||||
// first. It belongs in this list because the list answers "what may I write",
|
||||
// not "what survives normalization".
|
||||
var workbookCreateCellStyleFieldList = []string{
|
||||
"font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
"background_color", "horizontal_alignment", "vertical_alignment",
|
||||
"number_format", "word_wrap", "border", "border_styles",
|
||||
}
|
||||
|
||||
func workbookCreateCellStyleField(name string) bool {
|
||||
switch name {
|
||||
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
|
||||
@@ -1299,7 +1648,7 @@ func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
|
||||
switch side {
|
||||
case "top", "bottom", "left", "right":
|
||||
default:
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
|
||||
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
|
||||
}
|
||||
spec, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -1482,7 +1831,7 @@ func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID,
|
||||
}
|
||||
wireBody, _ := buildToolBody(toolName, input)
|
||||
dry.POST(toolInvokePath(token, ToolKindWrite)).
|
||||
Desc(fmt.Sprintf("apply %s %s", op.Kind, op.Range)).
|
||||
Desc(fmt.Sprintf("apply %s", op.describe())).
|
||||
Body(wireBody)
|
||||
}
|
||||
}
|
||||
@@ -1502,11 +1851,11 @@ func applyWorkbookCreateVisualOps(ctx context.Context, runtime *common.RuntimeCo
|
||||
// failing op as a recovery hint when one isn't already set.
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if p.Hint == "" {
|
||||
p.Hint = fmt.Sprintf("failed while applying %s on %s", op.Kind, op.Range)
|
||||
p.Hint = fmt.Sprintf("failed while applying %s", op.describe())
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "%s %s failed", op.Kind, op.Range).WithCause(err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "%s failed", op.describe()).WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1516,7 +1865,7 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
|
||||
if styles == nil {
|
||||
return nil
|
||||
}
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes))
|
||||
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes)+2)
|
||||
for _, op := range styles.CellMerges {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "cell_merge", Range: op.Range, MergeType: op.MergeType})
|
||||
}
|
||||
@@ -1526,6 +1875,9 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
|
||||
for _, op := range styles.ColSizes {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "col_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
|
||||
}
|
||||
if styles.Freeze != nil {
|
||||
ops = append(ops, workbookCreateStyleOp{Kind: "freeze", FreezeRows: styles.Freeze.Rows, FreezeCols: styles.Freeze.Cols})
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
@@ -1535,9 +1887,30 @@ type workbookCreateStyleOp struct {
|
||||
MergeType string
|
||||
ResizeType string
|
||||
Size int
|
||||
FreezeRows int
|
||||
FreezeCols int
|
||||
}
|
||||
|
||||
// describe renders the op for dry-run text and failure hints. freeze carries
|
||||
// counts instead of a range, so "%s %s" of kind and range would trail a blank.
|
||||
func (op workbookCreateStyleOp) describe() string {
|
||||
if op.Kind != "freeze" {
|
||||
return op.Kind + " " + op.Range
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if op.FreezeRows > 0 {
|
||||
parts = append(parts, fmt.Sprintf("rows=%d", op.FreezeRows))
|
||||
}
|
||||
if op.FreezeCols > 0 {
|
||||
parts = append(parts, fmt.Sprintf("cols=%d", op.FreezeCols))
|
||||
}
|
||||
return "freeze " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCreateStyleOp) (map[string]interface{}, string) {
|
||||
// Every caller names the sheet through the selector, so a "Sheet!" prefix
|
||||
// left on the range would be a duplicate the backend range parser rejects.
|
||||
op.Range = stripSheetPrefix(op.Range)
|
||||
switch op.Kind {
|
||||
case "cell_merge":
|
||||
input := map[string]interface{}{
|
||||
@@ -1564,6 +1937,26 @@ func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCr
|
||||
input["resize_width"] = block
|
||||
}
|
||||
return input, "resize_range"
|
||||
case "freeze":
|
||||
// Both axes travel in ONE operation because the backend treats freeze as
|
||||
// full-state replacement, not a per-axis patch: verified 07-31 on a live
|
||||
// sheet — freezing 1 row then 2 columns in two calls ends at
|
||||
// frozen_row_count 0 / frozen_column_count 2, the second call having
|
||||
// silently dropped the first axis. One call carrying both lands 1/2.
|
||||
// By the same rule an omitted axis is unfrozen, which is what a
|
||||
// declarative --styles spec should mean.
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "freeze",
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if op.FreezeRows > 0 {
|
||||
input["freeze_rows"] = op.FreezeRows
|
||||
}
|
||||
if op.FreezeCols > 0 {
|
||||
input["freeze_columns"] = op.FreezeCols
|
||||
}
|
||||
return input, "modify_sheet_structure"
|
||||
default:
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
@@ -520,7 +520,8 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
|
||||
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
|
||||
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"styles item unknown key gets did-you-mean", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","freezee":{"rows":1}}]}`}, `unknown key "freezee" — did you mean "freeze"`},
|
||||
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
|
||||
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
|
||||
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
|
||||
@@ -689,3 +690,143 @@ func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStyleItemRangePrefixNormalization pins the "Sheet!" prefix handling at the
|
||||
// shared item parser, so all three --styles carriers (+workbook-create,
|
||||
// +table-put, +styles-put) behave the same: a prefix naming the item's own
|
||||
// sheet is stripped (row_sizes would otherwise fail parseA1Range), one naming a
|
||||
// different sheet is reported instead of silently retargeting.
|
||||
func TestStyleItemRangePrefixNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("own-sheet prefix strips across every section", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{
|
||||
"name": "Summary",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "Summary!A1:D1", "font_weight": "bold"}},
|
||||
"cell_merges": []interface{}{map[string]interface{}{"range": "'Summary'!A2:B2"}, "Summary!C2:D2"},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "Summary!2:3", "type": "pixel", "size": float64(32)}},
|
||||
"col_sizes": []interface{}{map[string]interface{}{"range": "'Summary'!A:C", "type": "pixel", "size": float64(120)}},
|
||||
}
|
||||
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
|
||||
if len(probs) > 0 {
|
||||
t.Fatalf("a redundant own-sheet prefix must be accepted: %v", probs)
|
||||
}
|
||||
got := []string{
|
||||
payload.CellStyles[0].Range,
|
||||
payload.CellMerges[0].Range, payload.CellMerges[1].Range,
|
||||
payload.RowSizes[0].Range, payload.ColSizes[0].Range,
|
||||
}
|
||||
want := []string{"A1:D1", "A2:B2", "C2:D2", "2:3", "A:C"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ranges = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("foreign-sheet prefix reported alongside the item's other issues", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{
|
||||
"name": "Summary",
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "Detail!A1:D1", "font_weight": "bold"}},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "2:3", "type": "custom", "size": float64(32)}},
|
||||
}
|
||||
_, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
|
||||
joined := make([]string, 0, len(probs))
|
||||
for _, p := range probs {
|
||||
joined = append(joined, p.Error())
|
||||
}
|
||||
all := strings.Join(joined, "\n")
|
||||
// Both must surface in one pass: stripping the mismatched prefix keeps the
|
||||
// section parser from burying the real issue under a syntax error.
|
||||
if !strings.Contains(all, `names sheet "Detail" but the item targets "Summary"`) {
|
||||
t.Fatalf("probs = %v, want the foreign-prefix issue", all)
|
||||
}
|
||||
if !strings.Contains(all, `row_sizes[0].type "custom" is invalid`) {
|
||||
t.Fatalf("probs = %v, want the type issue reported too", all)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unnamed item still gets its prefixes stripped", func(t *testing.T) {
|
||||
// +workbook-create --values' styles item needs no name (one sheet, not
|
||||
// yet named), but stripping must not be conditional on having one: the
|
||||
// section parsers feed ranges to parseA1Range, so a surviving prefix
|
||||
// turns an unambiguous spec into a malformed-range error. With no name
|
||||
// there is simply nothing to disagree with, so no mismatch is reported.
|
||||
t.Parallel()
|
||||
item := map[string]interface{}{
|
||||
"cell_styles": []interface{}{map[string]interface{}{"range": "Sheet1!A1:D1", "font_weight": "bold"}},
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "Sheet1!1:1", "size": float64(30)}},
|
||||
}
|
||||
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
|
||||
if len(probs) > 0 {
|
||||
t.Fatalf("unexpected probs: %v", probs)
|
||||
}
|
||||
if payload.CellStyles[0].Range != "A1:D1" {
|
||||
t.Fatalf("cell_styles range = %q, want the prefix stripped", payload.CellStyles[0].Range)
|
||||
}
|
||||
if payload.RowSizes[0].Range != "1:1" {
|
||||
t.Fatalf("row_sizes range = %q, want the prefix stripped", payload.RowSizes[0].Range)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all three carriers accept a prefixed row_sizes range", func(t *testing.T) {
|
||||
// The regression this guards: prefix stripping used to live only on the
|
||||
// named-item path, so +workbook-create --values (whose item carries no
|
||||
// name) still failed on "Sheet1!2:3" while +table-put / +styles-put
|
||||
// accepted it.
|
||||
t.Parallel()
|
||||
for _, name := range []string{"", "Sheet1"} {
|
||||
item := map[string]interface{}{
|
||||
"row_sizes": []interface{}{map[string]interface{}{"range": "Sheet1!2:3", "size": float64(30)}},
|
||||
}
|
||||
if name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
|
||||
if len(probs) > 0 {
|
||||
t.Fatalf("name=%q: unexpected probs: %v", name, probs)
|
||||
}
|
||||
if payload.RowSizes[0].Range != "2:3" {
|
||||
t.Fatalf("name=%q: range = %q, want %q", name, payload.RowSizes[0].Range, "2:3")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWorkbookCreateVisualOpInput pins what the shared visual-op builder emits:
|
||||
// one combined freeze operation, and a range with no sheet prefix (the sheet
|
||||
// travels in the selector).
|
||||
func TestWorkbookCreateVisualOpInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("freeze rows and columns share one operation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops := workbookCreateVisualOps(&workbookCreateStylePayload{
|
||||
Freeze: &workbookCreateFreezeOp{Rows: 1, Cols: 2},
|
||||
})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("ops = %d, want 1 combined freeze (a second call resets the first axis — verified live 07-31)", len(ops))
|
||||
}
|
||||
input, toolName := workbookCreateVisualOpInput(testToken, "sheet-id", "", ops[0])
|
||||
if toolName != "modify_sheet_structure" {
|
||||
t.Fatalf("toolName = %q", toolName)
|
||||
}
|
||||
if input["freeze_rows"] != 1 || input["freeze_columns"] != 2 {
|
||||
t.Fatalf("input = %v, want both axes", input)
|
||||
}
|
||||
if got := ops[0].describe(); got != "freeze rows=1 cols=2" {
|
||||
t.Errorf("describe() = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet prefix is stripped off the range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input, toolName := workbookCreateVisualOpInput(testToken, "", "Summary",
|
||||
workbookCreateStyleOp{Kind: "cell_merge", Range: "Summary!A1:B2", MergeType: "all"})
|
||||
if toolName != "merge_cells" || input["range"] != "A1:B2" {
|
||||
t.Fatalf("input = %v (%s), want range A1:B2", input, toolName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -38,7 +39,11 @@ import (
|
||||
|
||||
// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells
|
||||
// (JSON), with an optional --copy-to-range to replicate the written block
|
||||
// across a larger area (formula refs auto-shift).
|
||||
// across a larger area (formula refs auto-shift). The plural form --writes
|
||||
// ([{sheet_name, range, cells}, …]) fans scattered regions — cross-sheet
|
||||
// allowed — into ONE atomic batch_update: eval traces show "fix all broken
|
||||
// formulas across ranges/sheets" as the dominant homogeneous scenario still
|
||||
// hand-assembled as +batch-update operations arrays.
|
||||
var CellsSet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set",
|
||||
@@ -48,9 +53,31 @@ var CellsSet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set"),
|
||||
Validate: validateViaInput(cellsSetInput),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
|
||||
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
|
||||
`Scattered regions (e.g. fixing formulas across ranges/sheets): --writes '[{"sheet_name":…,"range":…,"cells":[[…]]}, …]' — one batch request (fail-fast, no rollback), sheet selector inside each item.`,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("writes") {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cellsSetWritesOps(runtime, token)
|
||||
return err
|
||||
}
|
||||
return validateViaInput(cellsSetInput)(ctx, runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
if runtime.Changed("writes") {
|
||||
ops, _ := cellsSetWritesOps(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
}
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := cellsSetInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
@@ -60,6 +87,21 @@ var CellsSet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("writes") {
|
||||
ops, err := cellsSetWritesOps(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -77,6 +119,120 @@ var CellsSet = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// cellsSetWritesOps parses --writes ([{sheet_name|sheet_id, range, cells}, …])
|
||||
// and expands it into set_cell_range operations for ONE atomic batch_update.
|
||||
// Single source of truth per item: the sheet selector LIVES IN THE ITEM (same
|
||||
// convention as +batch-update sub-ops and +styles-put items — no top-level
|
||||
// fallback, no precedence table to remember). Every item runs through the
|
||||
// exact standalone pipeline (key vocabulary, style acceptance layer, matrix
|
||||
// precheck, schema validation) via a per-item flag view, and item errors are
|
||||
// aggregated so one retry fixes them all.
|
||||
func cellsSetWritesOps(runtime *common.RuntimeContext, token string) ([]interface{}, error) {
|
||||
for _, conflicting := range []string{"range", "cells", "copy-to-range"} {
|
||||
if runtime.Changed(conflicting) {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes and --%s are mutually exclusive: single region → --range + --cells; multiple regions → --writes alone", conflicting)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("sheet-name")) != "" || strings.TrimSpace(runtime.Str("sheet-id")) != "" {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes does not accept a top-level sheet selector — put sheet_name (or sheet_id) inside each writes item, same as +batch-update sub-ops")
|
||||
}
|
||||
raw, err := requireJSONArray(runtime, "writes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes must be a non-empty JSON array of {sheet_name, range, cells} items")
|
||||
}
|
||||
if len(raw) > maxBatchOperations {
|
||||
return nil, sheetsValidationForFlag("writes", "--writes accepts at most %d items; got %d — merge adjacent regions or split into several calls", maxBatchOperations, len(raw))
|
||||
}
|
||||
topLevelOverwrite := runtime.Bool("allow-overwrite")
|
||||
ops := make([]interface{}, 0, len(raw))
|
||||
var probs []error
|
||||
var totalCells int64
|
||||
for i, v := range raw {
|
||||
item, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
probs = append(probs, common.ValidationErrorf("--writes[%d] must be an object like {\"sheet_name\":…,\"range\":…,\"cells\":[[…]]}", i))
|
||||
continue
|
||||
}
|
||||
if err := normalizeSubOpInputKeys("+cells-set", item); err != nil {
|
||||
probs = append(probs, common.ValidationErrorf("--writes[%d]: %v", i, err))
|
||||
continue
|
||||
}
|
||||
if topLevelOverwrite {
|
||||
if _, has := item["allow_overwrite"]; !has {
|
||||
item["allow_overwrite"] = true
|
||||
}
|
||||
}
|
||||
fv := newMapFlagViewForCommand("+cells-set", item)
|
||||
sheetID := strings.TrimSpace(fv.Str("sheet-id"))
|
||||
sheetName := strings.TrimSpace(fv.Str("sheet-name"))
|
||||
input, err := cellsSetInput(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
// Prefix with the item index WITHOUT flattening: cellsSetInput's
|
||||
// errors carry the domain's prescriptions in Hint (requireSheetSelector's
|
||||
// "+workbook-info" pointer, for one) and "%v" would render only the
|
||||
// message, silently costing exactly the guidance this path exists to
|
||||
// deliver. joinWritesValidationErrors re-reads both fields.
|
||||
probs = append(probs, prefixValidationIssue(fmt.Sprintf("--writes[%d]", i), err))
|
||||
continue
|
||||
}
|
||||
if cells, ok := input["cells"].([]interface{}); ok {
|
||||
for _, row := range cells {
|
||||
if r, ok := row.([]interface{}); ok {
|
||||
totalCells += int64(len(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := checkBatchStampBudget("writes", totalCells); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
if err := joinWritesValidationErrors(probs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// joinWritesValidationErrors mirrors joinStyleValidationErrors for --writes:
|
||||
// every item's first error in one message, so the whole payload is fixed in
|
||||
// a single retry.
|
||||
func joinWritesValidationErrors(probs []error) error {
|
||||
switch len(probs) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
// Re-attribute to the outer flag even for a single issue: the inner
|
||||
// error is scoped to a nested path and carries no Param, so an agent
|
||||
// would have to parse prose to learn which flag to fix. Message text
|
||||
// is preserved; only the typed attribution is added — and the inner
|
||||
// hint rides along, since a lone issue has the outer Hint slot free.
|
||||
msg, hint := aggregatedIssueParts(probs[0])
|
||||
verr := sheetsValidationForFlag("writes", "%s", msg).WithCause(probs[0])
|
||||
if hint != "" {
|
||||
verr = verr.WithHint("%s", hint)
|
||||
}
|
||||
return verr
|
||||
}
|
||||
const maxShown = 8
|
||||
msgs := make([]string, 0, len(probs))
|
||||
for _, e := range probs {
|
||||
msgs = append(msgs, aggregatedIssueText(e))
|
||||
}
|
||||
suffix := ""
|
||||
if len(msgs) > maxShown {
|
||||
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
|
||||
msgs = msgs[:maxShown]
|
||||
}
|
||||
return sheetsValidationForFlag("writes", "--writes has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix).
|
||||
WithCause(probs[0])
|
||||
}
|
||||
|
||||
func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -91,9 +247,13 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
|
||||
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if err := checkCellsMatchRange(cells, rangeStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"range": rangeStr,
|
||||
"cells": cells,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
@@ -124,7 +284,11 @@ var CellsSetStyle = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set-style"),
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
Tips: []string{
|
||||
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
|
||||
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
|
||||
},
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
@@ -310,33 +474,92 @@ func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
|
||||
// guardCSVValueIsNotFilePath catches the common slip of passing a CSV file path
|
||||
// to --csv without the "@" that reads it (e.g. `--csv data.csv` instead of
|
||||
// `--csv @data.csv`). Because any string is a valid one-cell CSV, the mistake
|
||||
// would otherwise be written silently as the literal text "data.csv". It runs
|
||||
// in +csv-put's Validate, after resolveInputFlags — so an @file / stdin value is
|
||||
// already its contents (a real CSV blob, never a path) and only a bare value
|
||||
// reaches here unchanged. It flags the value only when it actually names an
|
||||
// existing file in the cwd subtree; checking real existence (not name shape)
|
||||
// means inline content that merely ends in a filename ("see config.json") is
|
||||
// never misjudged. Fails open: any Stat error or a directory leaves the value
|
||||
// untouched. Scoped to --csv only — no other flag is affected.
|
||||
// would otherwise be written silently as the literal text "data.csv" — a wrong
|
||||
// value in the sheet plus a success exit code, which costs more than a
|
||||
// rejection because nothing surfaces it. It runs in +csv-put's Validate, after
|
||||
// resolveInputFlags — so an @file / stdin value is already its contents (a real
|
||||
// CSV blob, never a path) and only a bare value reaches here unchanged.
|
||||
//
|
||||
// Two tiers, because the fix differs:
|
||||
//
|
||||
// - the value names an existing file in the cwd subtree → a forgotten "@";
|
||||
// - the file does not exist but the value is unmistakably path-shaped →
|
||||
// usually an absolute path (which "@" rejects) that the caller retried
|
||||
// without the "@", or a stale relative path from another working
|
||||
// directory. Same silent-write outcome, different prescription: stdin.
|
||||
//
|
||||
// Everything else passes through. Existence alone can't carry tier two, so
|
||||
// shape does — but only the narrow shape defined by csvValueLooksLikePath,
|
||||
// which is what keeps prose that merely mentions a filename out of it.
|
||||
// Fails open: any Stat error or a directory falls through to the shape check.
|
||||
// Scoped to --csv only — no other flag is affected.
|
||||
//
|
||||
// A value that arrived via @file / stdin is skipped entirely
|
||||
// (InputResolvedFromSource): its content was already read from the right
|
||||
// place and may legitimately look like anything, including a path. That
|
||||
// also makes stdin the guard-proof way to write such text verbatim.
|
||||
func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error {
|
||||
if runtime.InputResolvedFromSource("csv") {
|
||||
return nil
|
||||
}
|
||||
raw := strings.TrimSpace(runtime.Str("csv"))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
// Hints below use <path> placeholders instead of echoing the raw value
|
||||
// into command-shaped text: the value is untrusted, and a hint like
|
||||
// "--csv - < $(id).csv" hands an agent a copy-pasteable command that a
|
||||
// POSIX shell would expand.
|
||||
if fio := runtime.FileIO(); fio != nil {
|
||||
info, err := fio.Stat(raw)
|
||||
if err == nil && info != nil && !info.IsDir() {
|
||||
return sheetsValidationForFlag("csv",
|
||||
"--csv value %q is an existing file, not inline CSV; to read it, pass the same path with an @ prefix (--csv @<path>), or pipe the literal text via stdin (--csv -)",
|
||||
raw,
|
||||
)
|
||||
}
|
||||
}
|
||||
if !csvValueLooksLikePath(raw) {
|
||||
return nil
|
||||
}
|
||||
info, err := fio.Stat(raw)
|
||||
if err != nil || info == nil || info.IsDir() {
|
||||
return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @
|
||||
}
|
||||
return sheetsValidationForFlag("csv",
|
||||
"--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)",
|
||||
raw, raw,
|
||||
"--csv value %q looks like a file path, not inline CSV, and no such file exists under the current directory",
|
||||
raw,
|
||||
).WithHint(
|
||||
"to read a file: --csv @<path> (relative to the current directory; @ rejects absolute paths — pipe such a file in via stdin instead: --csv - < <path>). To write this text into the cell verbatim, pass it on stdin the same way (--csv -); values arriving via stdin or @file skip this check",
|
||||
)
|
||||
}
|
||||
|
||||
// csvValueLooksLikePath reports whether a --csv value is unmistakably a path
|
||||
// rather than CSV content. Deliberately narrow: the guard rejects on it, so a
|
||||
// false positive blocks a legitimate write, and an earlier name-shape
|
||||
// heuristic was replaced by an existence check precisely because it misjudged
|
||||
// prose ("改完记得更新config.json"). Three conditions, all required:
|
||||
//
|
||||
// no comma / newline / whitespace — real CSV has separators, prose has spaces
|
||||
// pure ASCII — CJK text is content, never a path here
|
||||
// a .csv/.tsv extension, or an explicit ./ ../ / ~/ prefix
|
||||
//
|
||||
// The extension-or-prefix rule is what keeps ordinary single-cell values safe:
|
||||
// "N/A" contains a slash but neither, and "README.md" is a filename but not a
|
||||
// CSV one. A caller who genuinely means such a literal still has stdin.
|
||||
func csvValueLooksLikePath(s string) bool {
|
||||
if strings.ContainsAny(s, ", \t\r\n\"") {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
if strings.HasSuffix(lower, ".csv") || strings.HasSuffix(lower, ".tsv") {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(s, "./") || strings.HasPrefix(s, "../") ||
|
||||
strings.HasPrefix(s, "/") || strings.HasPrefix(s, "~/")
|
||||
}
|
||||
|
||||
func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
@@ -625,6 +848,43 @@ func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) {
|
||||
// and returns its row / column counts. Errors on non-rectangular forms like
|
||||
// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total
|
||||
// from get_sheet_structure, outside the scope of pure local parsing.
|
||||
// checkCellsMatchRange rejects, before any network call, the cells-vs-range
|
||||
// mismatches the server would otherwise fail mid-batch ("cells row count (N)
|
||||
// does not match range row count (M)" — a recurring server-side error cluster
|
||||
// in eval traces, and the failure leaves earlier batch sub-ops applied).
|
||||
// Single-cell ranges are checked too: the server enforces the same strict
|
||||
// match on a bare "A1" (07-21 rerun, 12 rows against range row count 1) —
|
||||
// there is no anchor semantics on +cells-set. An unparsable range is the
|
||||
// range validator's job, not ours.
|
||||
func checkCellsMatchRange(cells []interface{}, rangeStr string) error {
|
||||
if len(cells) == 0 {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells is empty; to clear values use +cells-clear --scope content (needs --yes), or pass a non-empty 2D array")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // an unparsable range is reported by the range validation path with proper context
|
||||
}
|
||||
if len(cells) != rows {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells has %d rows but --range %q spans %d rows; make them equal (e.g. write N rows to an N-row range)",
|
||||
len(cells), rangeStr, rows)
|
||||
}
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells[%d] must be an array (one row of cells) — --cells is always a 2D array, a single cell is [[{…}]]", r)
|
||||
}
|
||||
if len(row) != cols {
|
||||
return sheetsValidationForFlag("cells",
|
||||
"--cells[%d] has %d columns but --range %q spans %d columns; every row must match the range width",
|
||||
r, len(row), rangeStr, cols)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rangeDimensions(rangeStr string) (rows, cols int, err error) {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
rangeStr = rangeStr[idx+1:]
|
||||
|
||||
189
shortcuts/sheets/read_output.go
Normal file
189
shortcuts/sheets/read_output.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet read → file offload ───────────────────────────────────
|
||||
//
|
||||
// Shared plumbing for +cells-get / +csv-get / +table-get behind the
|
||||
// --output-path flag: when a caller redirects a read to a file, the char cap
|
||||
// rises to a bounded offload default (see outputPathReadLimit) so a large
|
||||
// sheet lands on disk instead of being clipped by the stdout-oriented
|
||||
// max_chars safety cap — bounded, not unlimited, and the receipt states
|
||||
// whether the file is complete.
|
||||
|
||||
// readOutputPath returns the trimmed --output-path flag value ("" when unset).
|
||||
func readOutputPath(runtime *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(runtime.Str("output-path"))
|
||||
}
|
||||
|
||||
// outputPathReadLimit is the max_chars default when --output-path is set and
|
||||
// --max-chars was left alone. Deliberately bounded: the read path is not
|
||||
// streaming — the HTTP body, the tool's output string, the decoded JSON tree
|
||||
// and the re-marshalled pretty JSON all coexist in memory before the file is
|
||||
// written, so an effectively-unlimited cap turns "offload to disk" into an
|
||||
// OOM vector in CLI/sidecar processes. 20M chars keeps the multi-copy peak
|
||||
// in the low hundreds of MB; a caller who really wants more states it via an
|
||||
// explicit --max-chars, which always wins.
|
||||
const outputPathReadLimit = 20_000_000
|
||||
|
||||
// maxCharsInput resolves the max_chars value to send to the underlying read
|
||||
// tool. A cap the user set explicitly always binds — --output-path only
|
||||
// raises the default (to the bounded outputPathReadLimit) when --max-chars
|
||||
// was left alone, so a full read lands in the file without silently
|
||||
// discarding a requested limit.
|
||||
//
|
||||
// --max-chars 0 (or negative) means "no cap of my own", and is deliberately
|
||||
// NOT passed through as "send nothing": omitting max_chars makes the tool
|
||||
// apply its own ~50000 fallback, i.e. a caller asking for no limit would get
|
||||
// the SMALLEST one — the opposite of the request, and silently. It resolves
|
||||
// to the same ceiling an unset flag would: the offload limit when writing to
|
||||
// a file, otherwise the flag's declared default.
|
||||
//
|
||||
// The second return is false only when there is no cap to send at all, which
|
||||
// today means the flag is absent from this shortcut.
|
||||
func maxCharsInput(runtime *common.RuntimeContext) (int, bool) {
|
||||
if n := runtime.Int("max-chars"); n > 0 && runtime.Changed("max-chars") {
|
||||
return n, true
|
||||
}
|
||||
if readOutputPath(runtime) != "" {
|
||||
return outputPathReadLimit, true
|
||||
}
|
||||
// The flag's own default (500000) — reached both when it is unset and when
|
||||
// it was explicitly zeroed.
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
return n, true
|
||||
}
|
||||
if runtime.Changed("max-chars") {
|
||||
return maxCharsFallback, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// maxCharsFallback is the ceiling used when a caller explicitly asks for no
|
||||
// cap (--max-chars 0) without redirecting to a file. It matches the flag's
|
||||
// declared default rather than the tool's much smaller omitted-value
|
||||
// fallback, and stays well inside the non-streaming read path's memory
|
||||
// budget (see outputPathReadLimit); a caller who wants more says so with a
|
||||
// positive --max-chars or --output-path.
|
||||
const maxCharsFallback = 500_000
|
||||
|
||||
// maxCharsBudget returns the char cap that bounds a whole multi-sheet read
|
||||
// (0 when no cap is in play). Callers that read several sheets in one command
|
||||
// spend this budget across all of them rather than per sheet.
|
||||
func maxCharsBudget(runtime *common.RuntimeContext) int {
|
||||
if n, ok := maxCharsInput(runtime); ok {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// consumedChars approximates how much of the char budget the sheets read so
|
||||
// far have used, by the serialized size of what came back. The cap is a
|
||||
// server-side char count on the raw read, so this is an estimate — it is used
|
||||
// only to stop before the budget is blown, never to claim exact accounting.
|
||||
func consumedChars(sheets []interface{}) int {
|
||||
if len(sheets) == 0 {
|
||||
return 0
|
||||
}
|
||||
b, err := json.Marshal(sheets)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(b)
|
||||
}
|
||||
|
||||
// readResultTruncated reports whether a read payload carries any truncation
|
||||
// marker, at any of the three levels a read result can carry one: the top
|
||||
// level (budget exhausted before every sheet was read), a per-range entry
|
||||
// (+cells-get / +csv-get return ranges[]), or a per-sheet entry (+table-get
|
||||
// returns sheets[]). Missing a level makes the receipt claim complete:true
|
||||
// over a clipped file, which is worse than no receipt at all — an agent would
|
||||
// analyze or write back half the data believing it had all of it.
|
||||
func readResultTruncated(out interface{}) bool {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if truncationFlagSet(m) {
|
||||
return true
|
||||
}
|
||||
for _, key := range []string{"sheets", "ranges"} {
|
||||
items, ok := m[key].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, it := range items {
|
||||
im, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// A sheet entry can itself carry ranges[]; recurse so nesting
|
||||
// cannot hide a marker.
|
||||
if truncationFlagSet(im) || readResultTruncated(im) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncationFlagSet reports whether a single object carries a truncation
|
||||
// signal under any of the names the read tools use.
|
||||
func truncationFlagSet(m map[string]interface{}) bool {
|
||||
for _, key := range []string{"truncated", "has_more", "is_truncated"} {
|
||||
if v, ok := m[key].(bool); ok && v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// emitReadResult delivers a read shortcut's result. When --output-path is set it
|
||||
// writes the data payload to that path as pretty JSON and prints a small
|
||||
// confirmation envelope to stdout; otherwise it prints the full result envelope
|
||||
// to stdout as usual. The receipt always states completeness: the char cap is
|
||||
// bounded, so "written to a file" does not by itself mean "the whole sheet is
|
||||
// in that file", and a caller must not have to re-open the file to find out.
|
||||
func emitReadResult(runtime *common.RuntimeContext, out interface{}) error {
|
||||
path := readOutputPath(runtime)
|
||||
if path == "" {
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
b, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(b)); err != nil {
|
||||
// Typed mapping keeps an unsafe --output-path a validation error and
|
||||
// write failures file_io — a raw Save error surfaces as internal/unknown.
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolved, err := runtime.FileIO().ResolvePath(path)
|
||||
if err != nil {
|
||||
resolved = path
|
||||
}
|
||||
receipt := map[string]interface{}{
|
||||
"output_path": resolved,
|
||||
"bytes_written": len(b),
|
||||
"complete": true,
|
||||
}
|
||||
if readResultTruncated(out) {
|
||||
receipt["complete"] = false
|
||||
receipt["truncated"] = true
|
||||
receipt["truncation_warning"] = "the read hit the char cap, so the file holds a partial result — inspect truncated / unread_sheets inside it, then re-read the missing part with --range or per --sheet-name, or raise --max-chars"
|
||||
}
|
||||
runtime.Out(receipt, nil)
|
||||
return nil
|
||||
}
|
||||
214
shortcuts/sheets/read_output_test.go
Normal file
214
shortcuts/sheets/read_output_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestReadOutputPath_UnsafePathIsTypedValidation pins the error contract of
|
||||
// the --output-path save seam: an escaping path must come back as a
|
||||
// validation error with the path-validation cause preserved, not as
|
||||
// internal/unknown from the raw FileIO.Save error.
|
||||
func TestReadOutputPath_UnsafePathIsTypedValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"output": `{"values":[["x"]]}`},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, CellsGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1",
|
||||
"--output-path", "../../outside.json", "--as", "user",
|
||||
}, stub)
|
||||
ve := requireValidation(t, err, "unsafe output path")
|
||||
if ve.Cause == nil || !errors.Is(ve.Cause, fileio.ErrPathValidation) {
|
||||
t.Errorf("Cause = %v, want the fileio.ErrPathValidation chain preserved", ve.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadResultTruncated_AllLevels pins the completeness classifier: a
|
||||
// truncation marker at ANY level must be seen, or the --output-path receipt
|
||||
// claims complete:true over a clipped file and an agent analyzes half the
|
||||
// data believing it has all of it.
|
||||
func TestReadResultTruncated_AllLevels(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
out interface{}
|
||||
want bool
|
||||
}{
|
||||
{"top-level truncated", map[string]interface{}{"truncated": true}, true},
|
||||
{"top-level has_more", map[string]interface{}{"has_more": true}, true},
|
||||
{"ranges entry", map[string]interface{}{"ranges": []interface{}{map[string]interface{}{"truncated": true}}}, true},
|
||||
{"sheets entry", map[string]interface{}{"sheets": []interface{}{map[string]interface{}{"truncated": true}}}, true},
|
||||
{"nested ranges inside a sheet", map[string]interface{}{
|
||||
"sheets": []interface{}{map[string]interface{}{
|
||||
"ranges": []interface{}{map[string]interface{}{"truncated": true}},
|
||||
}},
|
||||
}, true},
|
||||
{"clean payload", map[string]interface{}{"sheets": []interface{}{map[string]interface{}{"data": []interface{}{}}}}, false},
|
||||
{"non-map payload", []interface{}{1, 2}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := readResultTruncated(tc.out); got != tc.want {
|
||||
t.Errorf("readResultTruncated = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxCharsInput_ExplicitZero pins that asking for "no cap of my own" does
|
||||
// not land on the SMALLEST cap. Omitting max_chars makes the read tool apply
|
||||
// its own ~50000 fallback, so passing the request straight through would give
|
||||
// --max-chars 0 a tighter limit than leaving the flag alone — the opposite of
|
||||
// what it reads like, and silently.
|
||||
func TestMaxCharsInput_ExplicitZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("resolves to whatever omitting the flag resolves to", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Compared against the omitted call rather than against maxCharsFallback:
|
||||
// asserting the constant equals itself would pass even after the flag's
|
||||
// declared default moved in flag-defs.json and left the two out of step.
|
||||
// The contract is "0 means no cap of my own", i.e. behave as if unset.
|
||||
zero := cellsGetToolInput(t, []string{"--max-chars", "0"})
|
||||
omitted := cellsGetToolInput(t, nil)
|
||||
got, ok := zero["max_chars"]
|
||||
if !ok {
|
||||
t.Fatalf("max_chars must be sent, or the tool's ~50000 fallback binds: %#v", zero)
|
||||
}
|
||||
if want := omitted["max_chars"]; got != want {
|
||||
t.Errorf("--max-chars 0 sent max_chars=%v, omitting it sent %v; they must agree", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--output-path still raises it to the offload limit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := cellsGetToolInput(t, []string{"--max-chars", "0", "--output-path", "./o.json"})
|
||||
if got := input["max_chars"]; got != float64(outputPathReadLimit) {
|
||||
t.Errorf("max_chars = %v, want %d", got, outputPathReadLimit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a positive explicit cap still wins over --output-path", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := cellsGetToolInput(t, []string{"--max-chars", "1234", "--output-path", "./o.json"})
|
||||
if got := input["max_chars"]; got != float64(1234) {
|
||||
t.Errorf("max_chars = %v, want 1234", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func cellsGetToolInput(t *testing.T, extra []string) map[string]interface{} {
|
||||
t.Helper()
|
||||
args := append([]string{"--url", testURL, "--sheet-name", "S1", "--range", "A1:B2"}, extra...)
|
||||
return decodeToolInput(t, parseDryRunBody(t, CellsGet, args), "get_cell_ranges")
|
||||
}
|
||||
|
||||
// TestEmitReadResult_ReceiptStatesCompleteness drives a real --output-path read
|
||||
// end to end and checks the stdout receipt against the payload written to disk.
|
||||
//
|
||||
// The receipt is the ONLY completeness signal a caller gets on this path — the
|
||||
// data went to a file, stdout carries just the summary — and the skill docs
|
||||
// instruct agents to read `complete` before using the file. Nothing was pinning
|
||||
// it: hard-coding complete:true passed the whole suite, which is exactly the
|
||||
// failure that makes an agent analyze half a sheet believing it has all of it.
|
||||
//
|
||||
// Not parallel: t.Chdir scopes the relative --output-path to a temp dir.
|
||||
func TestEmitReadResult_ReceiptStatesCompleteness(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
output string
|
||||
wantComplete bool
|
||||
}{
|
||||
{
|
||||
name: "clean read reports complete",
|
||||
output: `{"ranges":[{"range":"A1:B2","values":[["x","y"]]}]}`,
|
||||
wantComplete: true,
|
||||
},
|
||||
{
|
||||
name: "per-range truncation flag reports incomplete",
|
||||
output: `{"ranges":[{"range":"A1:B2","truncated":true,"values":[["x","y"]]}]}`,
|
||||
wantComplete: false,
|
||||
},
|
||||
{
|
||||
name: "top-level has_more reports incomplete",
|
||||
output: `{"has_more":true,"ranges":[{"range":"A1:B2","values":[["x","y"]]}]}`,
|
||||
wantComplete: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// os.Chdir + Cleanup rather than t.Chdir: the latter is Go 1.24,
|
||||
// and go.mod declares 1.23.0 (CI resolves its toolchain from it).
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(orig) })
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"output": tc.output},
|
||||
},
|
||||
}
|
||||
stdout, err := runShortcutWithStubs(t, CellsGet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2",
|
||||
"--output-path", "out.json", "--as", "user",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
receipt := decodeEnvelopeData(t, stdout)
|
||||
if got := receipt["complete"]; got != tc.wantComplete {
|
||||
t.Errorf("complete = %v, want %v (receipt=%v)", got, tc.wantComplete, receipt)
|
||||
}
|
||||
if !tc.wantComplete {
|
||||
if receipt["truncated"] != true {
|
||||
t.Errorf("an incomplete receipt must also set truncated:true, got %v", receipt)
|
||||
}
|
||||
if w, _ := receipt["truncation_warning"].(string); w == "" {
|
||||
t.Error("an incomplete receipt must carry a truncation_warning telling the caller what to do")
|
||||
}
|
||||
} else if _, has := receipt["truncated"]; has {
|
||||
t.Errorf("a complete receipt must not carry a truncation marker, got %v", receipt)
|
||||
}
|
||||
|
||||
// The file must actually hold the payload, not the receipt.
|
||||
written, readErr := os.ReadFile(filepath.Join(dir, "out.json"))
|
||||
if readErr != nil {
|
||||
t.Fatalf("output file not written: %v", readErr)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(written, &payload); err != nil {
|
||||
t.Fatalf("output file is not JSON: %v", err)
|
||||
}
|
||||
if _, has := payload["ranges"]; !has {
|
||||
t.Errorf("file should hold the data payload, got %s", written)
|
||||
}
|
||||
if n, _ := receipt["bytes_written"].(float64); int(n) != len(written) {
|
||||
t.Errorf("bytes_written = %v, file is %d bytes", receipt["bytes_written"], len(written))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -83,7 +84,11 @@ func callTool(
|
||||
code, _ := util.ToFloat64(envelope["code"])
|
||||
if code != 0 {
|
||||
msg, _ := envelope["msg"].(string)
|
||||
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
|
||||
// The recovery prescription depends on the execution mode the batch
|
||||
// was sent with; non-batch tools simply lack the key (false).
|
||||
continueOnError, _ := input["continue_on_error"].(bool)
|
||||
flat := flattenToolErrorMsg(msg, continueOnError, callerAuthoredOperations(runtime.Command()))
|
||||
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), flat).
|
||||
WithCode(int(code))
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
@@ -100,6 +105,92 @@ func callTool(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// flattenToolErrorMsg unwraps the nested-escaped-JSON error payload some
|
||||
// sheet-ai tools put in msg — batch_update in particular wraps its result as
|
||||
// {"error":"{\"message\":\"batch_update: N succeeded, M failed\",
|
||||
// \"failures\":[…]}","errorType":…,"data":{…}} — into one readable line
|
||||
// naming each failed operation. Eval traces show agents (and even the eval
|
||||
// aggregator) failing to extract the real cause from the double-escaped
|
||||
// form. Anything that doesn't match the nested shape passes through
|
||||
// untouched.
|
||||
//
|
||||
// continueOnError is the execution mode the batch was sent with: it decides
|
||||
// the recovery prescription, because a single listed failure only implies
|
||||
// "nothing after it ran" under fail-fast.
|
||||
//
|
||||
// callerAuthoredOps says whether the operations array the server indexes into
|
||||
// is the one the CALLER wrote. Only +batch-update's --operations is; every
|
||||
// other batch_update user (+styles-put, +cells-set --writes, +dim-delete
|
||||
// --ranges, the fan-out stampers) synthesizes the array client-side, and
|
||||
// +styles-put coalesces while +dim-delete deliberately re-sorts descending —
|
||||
// so "operations[3]" there names nothing the caller can find, and
|
||||
// "resend operations[3:]" is not a command they can issue. Those callers get
|
||||
// the per-op detail (still the best available description of what failed) plus
|
||||
// a generic no-rollback warning, never an index-based resend instruction.
|
||||
func flattenToolErrorMsg(msg string, continueOnError, callerAuthoredOps bool) string {
|
||||
trimmed := strings.TrimSpace(msg)
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return msg
|
||||
}
|
||||
var outer struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal([]byte(trimmed), &outer) != nil || strings.TrimSpace(outer.Error) == "" {
|
||||
return msg
|
||||
}
|
||||
inner := strings.TrimSpace(outer.Error)
|
||||
var detail struct {
|
||||
Message string `json:"message"`
|
||||
Failures []struct {
|
||||
Index int `json:"index"`
|
||||
ToolName string `json:"tool_name"`
|
||||
Error string `json:"error"`
|
||||
} `json:"failures"`
|
||||
}
|
||||
if strings.HasPrefix(inner, "{") && json.Unmarshal([]byte(inner), &detail) == nil && detail.Message != "" {
|
||||
if len(detail.Failures) == 0 {
|
||||
return detail.Message
|
||||
}
|
||||
parts := make([]string, 0, len(detail.Failures))
|
||||
firstFailed := detail.Failures[0].Index
|
||||
for _, f := range detail.Failures {
|
||||
parts = append(parts, fmt.Sprintf("operations[%d] (%s): %s", f.Index, f.ToolName, f.Error))
|
||||
if f.Index < firstFailed {
|
||||
firstFailed = f.Index
|
||||
}
|
||||
}
|
||||
out := detail.Message + " — " + strings.Join(parts, "; ")
|
||||
// Partial failure is NOT rolled back server-side: the succeeded sub-ops
|
||||
// stay applied. Spell out the recovery so agents don't resend the whole
|
||||
// batch and double-apply the successes (observed in eval traces). Only
|
||||
// under fail-fast does a single failure mean nothing after it ran —
|
||||
// resend from that index. Under continue-on-error the later operations
|
||||
// already executed, so even a single listed failure must be resent
|
||||
// alone; prescribing the tail there would double-apply the successes.
|
||||
if strings.Contains(detail.Message, "succeeded") &&
|
||||
!strings.Contains(detail.Message, " 0 succeeded") {
|
||||
switch {
|
||||
case !callerAuthoredOps:
|
||||
// Client-side expansion: the indexes above are internal, so
|
||||
// prescribe a read-back instead of an un-issuable resend.
|
||||
out += "; note: this command expands into the operations above client-side, so their indexes are not something you can resend directly. Succeeded operations stay applied (no rollback) — read the affected area back (+sheet-info / +cells-get), then re-issue only the part that did not land"
|
||||
case !continueOnError && len(detail.Failures) == 1:
|
||||
out += fmt.Sprintf("; note: succeeded operations stay applied (no rollback) — fix the failure and resend only operations[%d:] onward, do not resend the whole batch", firstFailed)
|
||||
default:
|
||||
out += "; note: succeeded operations stay applied (no rollback) — fix and resend only the failed operations listed above, do not resend the whole batch"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// callerAuthoredOperations reports whether `command` is the one shortcut whose
|
||||
// batch_update operations array the caller wrote by hand. Everything else
|
||||
// synthesizes it, so server-reported operation indexes are internal detail
|
||||
// there (see flattenToolErrorMsg).
|
||||
func callerAuthoredOperations(command string) bool { return command == "+batch-update" }
|
||||
|
||||
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
|
||||
// The wire-format body (with input serialized to a JSON string) is preserved
|
||||
// for fidelity, and a decoded tool_input map is surfaced alongside so humans
|
||||
|
||||
112
shortcuts/sheets/sheet_ai_api_flatten_test.go
Normal file
112
shortcuts/sheets/sheet_ai_api_flatten_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFlattenToolErrorMsg pins the unwrap of batch_update's double-escaped
|
||||
// error payload (the exact shape from eval V2U038/V2U013 traces) and the
|
||||
// pass-through of everything else.
|
||||
func TestFlattenToolErrorMsg(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("batch failures flatten to one line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `{"error":"{\"message\":\"batch_update: 0 succeeded, 1 failed\",\"succeeded\":0,\"failed\":1,\"failures\":[{\"index\":0,\"tool_name\":\"manage_chart_object\",\"error\":\"invalid snapshot.data.dim1.serie.index: 0, must be >= 1 (index is 1-based)\",\"errorType\":\"param_error\"}]}","errorType":"param_error","data":{"total":2,"succeeded":0,"failed":1}}`
|
||||
got := flattenToolErrorMsg(msg, false, true)
|
||||
for _, want := range []string{
|
||||
"batch_update: 0 succeeded, 1 failed",
|
||||
"operations[0] (manage_chart_object): invalid snapshot.data.dim1.serie.index",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("flattened msg should contain %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `\"`) {
|
||||
t.Errorf("flattened msg must not carry escaped JSON, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain-string inner error unwraps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := flattenToolErrorMsg(`{"error":"sheet \"s\" not found","errorType":"param_error"}`, false, true)
|
||||
if got != `sheet "s" not found` {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-JSON msg passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `cell at row 0, col 1 is inside a merged region (top-left: A1)`
|
||||
if got := flattenToolErrorMsg(msg, false, true); got != msg {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON without error field passes through", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := `{"detail":"x"}`
|
||||
if got := flattenToolErrorMsg(msg, false, true); got != msg {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFlattenToolErrorMsg_OperationIndexProvenance pins who may be told to
|
||||
// "resend operations[N:]". Only +batch-update's --operations is written by the
|
||||
// caller; +styles-put, +cells-set --writes, +dim-delete --ranges and the
|
||||
// fan-out stampers synthesize the array — +styles-put even coalesces adjacent
|
||||
// stamps and +dim-delete deliberately re-sorts descending, so an index there
|
||||
// names nothing the caller can locate, let alone resend.
|
||||
func TestFlattenToolErrorMsg_OperationIndexProvenance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const msg = `{"error":"{\"message\":\"batch_update: 4 succeeded, 1 failed\",\"failures\":[{\"index\":4,\"tool_name\":\"set_cell_range\",\"error\":\"cells is required\"}]}","errorType":"param_error"}`
|
||||
|
||||
t.Run("caller-authored operations get the index-based resend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := flattenToolErrorMsg(msg, false, true)
|
||||
if !strings.Contains(got, "resend only operations[4:] onward") {
|
||||
t.Errorf("want the index-based prescription, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("client-side expansion gets a read-back instead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := flattenToolErrorMsg(msg, false, false)
|
||||
if strings.Contains(got, "resend only operations[") {
|
||||
t.Errorf("must not prescribe an index the caller never wrote, got %q", got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"expands into the operations above client-side",
|
||||
"stay applied (no rollback)",
|
||||
"read the affected area back",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("want %q in the prescription, got %q", want, got)
|
||||
}
|
||||
}
|
||||
// The per-op detail is still the best description of what failed.
|
||||
if !strings.Contains(got, "operations[4] (set_cell_range): cells is required") {
|
||||
t.Errorf("per-op detail must survive, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCallerAuthoredOperations names the one shortcut whose operations array
|
||||
// is the caller's own. Every other batch_update user builds it.
|
||||
func TestCallerAuthoredOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !callerAuthoredOperations("+batch-update") {
|
||||
t.Error("+batch-update writes its own --operations")
|
||||
}
|
||||
for _, sc := range []string{"+styles-put", "+cells-set", "+dim-delete", "+cells-batch-clear", "+dropdown-update"} {
|
||||
if callerAuthoredOperations(sc) {
|
||||
t.Errorf("%s synthesizes its operations array client-side", sc)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,10 +209,10 @@ func TestTablePutCellBudgetIncludesStylePadding(t *testing.T) {
|
||||
// TestBatchStampAggregateCap covers the batch fan-out aggregate budget — the
|
||||
// per-range cap can't stop many ranges from summing past the matrix ceiling.
|
||||
func TestBatchStampAggregateCap(t *testing.T) {
|
||||
if err := checkBatchStampBudget(maxStampMatrixCells); err != nil {
|
||||
if err := checkBatchStampBudget("ranges", maxStampMatrixCells); err != nil {
|
||||
t.Fatalf("aggregate == cap should pass, got: %v", err)
|
||||
}
|
||||
if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil {
|
||||
if err := checkBatchStampBudget("ranges", maxStampMatrixCells+1); err == nil {
|
||||
t.Fatal("aggregate over cap should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ func Shortcuts() []common.Shortcut {
|
||||
if hasFlag(all[i].Flags, "spreadsheet-token") {
|
||||
all[i].PostMount = withTokenAlias(all[i].PostMount)
|
||||
}
|
||||
// +chart-create grows --print-example (minimal per-type --properties
|
||||
// templates) — the biggest --print-schema consumer in eval traces.
|
||||
if all[i].Command == "+chart-create" {
|
||||
all[i].PostMount = withChartPrintExample(all[i].PostMount)
|
||||
}
|
||||
// Sheets-scoped flag ergonomics (unknown-flag hints with the valid
|
||||
// flags inlined, enum vocabulary normalization) ride the same
|
||||
// PostMount composition, so no other domain's behavior shifts.
|
||||
@@ -153,6 +158,9 @@ func shortcutList() []common.Shortcut {
|
||||
SparklineCreate, SparklineUpdate, SparklineDelete,
|
||||
FloatImageCreate, FloatImageUpdate, FloatImageDelete,
|
||||
|
||||
// lark_sheet_styles_put
|
||||
StylesPut,
|
||||
|
||||
// lark_sheet_batch_update
|
||||
BatchUpdate,
|
||||
CellsBatchSetStyle,
|
||||
|
||||
597
shortcuts/sheets/style_vocab.go
Normal file
597
shortcuts/sheets/style_vocab.go
Normal file
@@ -0,0 +1,597 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── style vocabulary acceptance layer ────────────────────────────────
|
||||
//
|
||||
// The single home for how the sheets domain ACCEPTS style vocabulary, across
|
||||
// all three carrier paths that end in set_cell_range bodies:
|
||||
//
|
||||
// flag path +cells-set-style / +cells-batch-set-style flat flags
|
||||
// typed cells +cells-set --cells cell objects (incl. batch sub-ops)
|
||||
// styles payload --styles on +workbook-create / +table-put / +styles-put
|
||||
//
|
||||
// Design contract (established in the 2026-07 batch-update overhaul; see the
|
||||
// acceptance tests in styles_acceptance_test.go):
|
||||
//
|
||||
// - ONE canonical form, documented; a WIDE acceptance layer, undocumented.
|
||||
// Model priors are divergent (one eval batch produced six different
|
||||
// border spellings), so no canonical structure can make first tries
|
||||
// succeed — acceptance is normalized here instead, never per-call-site.
|
||||
// - Every rewrite must be unambiguous; ambiguous guesses (fore_color) get
|
||||
// a targeted prescription, never a silent pick. Silent ignoring and
|
||||
// bare rejection are both bugs.
|
||||
// - SILENT-ALIAS ADMISSION BAR (2026-07-21): only words from REAL external
|
||||
// vocabularies (Excel/openpyxl, CSS, Google Sheets API), recurring
|
||||
// across batches or ≥3 tasks in one, with zero semantic ambiguity.
|
||||
// Spelling/word-order permutations do NOT get aliases — they are
|
||||
// absorbed by the universal did-you-mean rejection (one self-healing
|
||||
// retry, zero per-variant code). Real vocabularies are a finite set;
|
||||
// permutations are not. Earlier permutation aliases are grandfathered.
|
||||
// - Closure is enforced by two test properties: vocabulary parity (every
|
||||
// flag-path style must be accepted on the payload paths) and the prior
|
||||
// corpus (every observed model spelling either normalizes or
|
||||
// prescribes). New eval finding → corpus row → fix HERE → locked.
|
||||
|
||||
// sortedKeys returns a map's keys in sorted order, so any loop that can abort
|
||||
// with an error reports a deterministic one. Used throughout this file: the
|
||||
// acceptance layer is all map-shaped vocabulary, and "which of my three bad
|
||||
// fields did it complain about" must not change between runs.
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
style := map[string]interface{}{}
|
||||
if v := runtime.Str("background-color"); v != "" {
|
||||
style["background_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
if v := runtime.Str("font-family"); v != "" {
|
||||
style["font_family"] = v
|
||||
}
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
if v := runtime.Str("font-style"); v != "" {
|
||||
style["font_style"] = v
|
||||
}
|
||||
if v := runtime.Str("font-weight"); v != "" {
|
||||
style["font_weight"] = v
|
||||
}
|
||||
if v := runtime.Str("font-line"); v != "" {
|
||||
style["font_line"] = v
|
||||
}
|
||||
if v := runtime.Str("horizontal-alignment"); v != "" {
|
||||
style["horizontal_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("vertical-alignment"); v != "" {
|
||||
style["vertical_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("word-wrap"); v != "" {
|
||||
style["word_wrap"] = v
|
||||
}
|
||||
if v := runtime.Str("number-format"); v != "" {
|
||||
style["number_format"] = v
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// cellStyleAliases maps shorthand cell_styles field names that models commonly
|
||||
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
|
||||
// names the backend expects. Only the unambiguous alignment shorthands are
|
||||
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
|
||||
// "bg_color", "text_align") are intentionally left out so a wrong guess still
|
||||
// surfaces as an error rather than being silently reinterpreted.
|
||||
var cellStyleAliases = []struct{ alias, canonical string }{
|
||||
{"horizontal_align", "horizontal_alignment"},
|
||||
{"halign", "horizontal_alignment"},
|
||||
{"vertical_align", "vertical_alignment"},
|
||||
{"valign", "vertical_alignment"},
|
||||
// wrap family: word_wrap is the sole wrap concept, no ambiguity. 07-20
|
||||
// eval: wrap_text alone produced an 88-issue retry loop on --styles;
|
||||
// wrap_strategy (the Google Sheets API word) followed on 07-21.
|
||||
{"wrap_text", "word_wrap"},
|
||||
{"text_wrap", "word_wrap"},
|
||||
{"wrap_strategy", "word_wrap"},
|
||||
}
|
||||
|
||||
// styleFieldPrescriptions carries the exact fix for high-frequency
|
||||
// unsupported cell_styles field names where the edit-distance suggester is
|
||||
// actively misleading (07-28 root-cause report: font_bold drew "did you mean
|
||||
// font_color?" and nested font drew "font_line" — an agent that follows
|
||||
// either burns a second failed round trip). Keyed by lowercased field name;
|
||||
// the text replaces the did-you-mean on the unsupported-field error. These
|
||||
// stay prescriptions, not silent aliases: bold/text_align are on the
|
||||
// deliberate no-alias list above.
|
||||
var styleFieldPrescriptions = map[string]string{
|
||||
"bold": `bold text is font_weight:"bold"`,
|
||||
"font_bold": `bold text is font_weight:"bold"`,
|
||||
"italic": `italic text is font_style:"italic"`,
|
||||
"underline": `underline is font_line:"underline"`,
|
||||
"text_align": "horizontal text alignment is horizontal_alignment (left/center/right)",
|
||||
"font": `cell_styles has no nested font object — use the flat font_* fields (font:{"bold":true,"size":18,"color":"#000"} becomes font_weight:"bold", font_size:18, font_color:"#000")`,
|
||||
}
|
||||
|
||||
// cellStyleEnumFields sources the enum vocabulary for enum-bearing
|
||||
// cell_styles fields from the +cells-set-style flag-defs, so the payload path
|
||||
// (--styles / typed --cells) validates and canonicalizes values the same way
|
||||
// the cobra flag path does. 07-20 eval: "vertical_alignment":"center" (CSS
|
||||
// vocabulary; Lark spells it "middle") passed the CLI and burned a
|
||||
// server-side round trip ~10 times — the flag path had normalized it since
|
||||
// round 2, the payload path never did.
|
||||
func cellStyleEnumFields() map[string][]string {
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
spec, ok := defs["+cells-set-style"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "own" || df.Type != "string" || len(df.Enum) == 0 {
|
||||
continue
|
||||
}
|
||||
out[strings.ReplaceAll(df.Name, "-", "_")] = df.Enum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cellStyleScalarTypes maps each scalar cell-style field to the JSON type
|
||||
// flag-defs declares for it ("string" / "number"), derived from
|
||||
// +cells-set-style's own flags so the two never drift. Composite fields
|
||||
// (border / border_styles, whose values are objects) are excluded — they have
|
||||
// their own structural validation.
|
||||
func cellStyleScalarTypes() map[string]string {
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
spec, ok := defs["+cells-set-style"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "own" {
|
||||
continue
|
||||
}
|
||||
name := strings.ReplaceAll(df.Name, "-", "_")
|
||||
switch name {
|
||||
case "range", "border_styles", "border":
|
||||
continue // locator / composite: validated structurally elsewhere
|
||||
}
|
||||
switch df.Type {
|
||||
case "string":
|
||||
out[name] = "string"
|
||||
case "float64", "int":
|
||||
out[name] = "number"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeCellStyleAliases renames known shorthand keys in a single
|
||||
// cell_styles map to their canonical equivalents, in place, so a model that
|
||||
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
|
||||
// applies the style instead of hitting an "unsupported field" error (--styles)
|
||||
// or having the field silently dropped by the backend (typed --cells). If both
|
||||
// the shorthand and its canonical key are present it returns a validation error
|
||||
// rather than picking one. It then canonicalizes enum VALUES (casing + known
|
||||
// cross-vocabulary aliases like CSS "center" → Lark "middle"; boolean
|
||||
// word_wrap → the enum) and rejects off-enum values client-side instead of
|
||||
// letting the server fail the whole batch. path labels the map for errors.
|
||||
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
|
||||
if len(style) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, a := range cellStyleAliases {
|
||||
v, ok := style[a.alias]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := style[a.canonical]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
|
||||
}
|
||||
style[a.canonical] = v
|
||||
delete(style, a.alias)
|
||||
}
|
||||
// fore_color is deliberately NOT aliased: in openpyxl vocabulary fgColor
|
||||
// is the FILL color while a plain reading suggests the font color — a
|
||||
// silent pick could color the wrong thing. Prescribe both options.
|
||||
if _, has := style["fore_color"]; has {
|
||||
return common.ValidationErrorf("%s.fore_color is ambiguous — use font_color for text color or background_color for the cell fill", path)
|
||||
}
|
||||
// Boolean wrap habit: true unambiguously means wrap on, false means off.
|
||||
if b, isBool := style["word_wrap"].(bool); isBool {
|
||||
if b {
|
||||
style["word_wrap"] = "auto-wrap"
|
||||
} else {
|
||||
style["word_wrap"] = "overflow"
|
||||
}
|
||||
}
|
||||
// Scalar style fields carry a declared type in flag-defs. --styles and the
|
||||
// typed --cells payloads bypass the generic JSON-schema pass (their schema
|
||||
// describes the outer envelope, not each cell_styles object), so assert the
|
||||
// declared type here — otherwise {"font_weight": true} sails through
|
||||
// normalization and reaches the server as a boolean.
|
||||
// Both loops below can abort with an error, so they walk their vocabulary in
|
||||
// sorted order: map iteration would let the same bad payload report a
|
||||
// different field on every run.
|
||||
scalarTypes := cellStyleScalarTypes()
|
||||
for _, field := range sortedKeys(scalarTypes) {
|
||||
want := scalarTypes[field]
|
||||
raw, has := style[field]
|
||||
if !has || raw == nil {
|
||||
continue
|
||||
}
|
||||
if got := jsType(raw); got != want {
|
||||
return common.ValidationErrorf("%s.%s must be a %s, got %s (%s)",
|
||||
path, field, want, got, formatJSONValue(raw))
|
||||
}
|
||||
}
|
||||
enumFields := cellStyleEnumFields()
|
||||
for _, field := range sortedKeys(enumFields) {
|
||||
enum := enumFields[field]
|
||||
raw, has := style[field]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
val, isStr := raw.(string)
|
||||
if !isStr || val == "" || slices.Contains(enum, val) {
|
||||
continue
|
||||
}
|
||||
if canon := canonicalEnumValue(val, enum); canon != "" {
|
||||
style[field] = canon
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf("%s.%s value %q is invalid (allowed: %s)", path, field, val, strings.Join(enum, ", "))
|
||||
if match := closestEnumValue(val, enum); match != "" {
|
||||
msg += fmt.Sprintf("; did you mean %q?", match)
|
||||
}
|
||||
return common.ValidationErrorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
|
||||
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
|
||||
// alignment shorthands are accepted on +cells-set the same as on --styles.
|
||||
// It also expands the border "all" shorthand and intercepts border_styles
|
||||
// mis-nested inside cell_styles — both server-rejected shapes that eval
|
||||
// traces show surviving CLI validation and costing a full network round
|
||||
// trip. Structure is checked leniently to match the pass-through contract:
|
||||
// any element that isn't the expected shape is skipped, not rejected.
|
||||
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for c, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// cells[][].style is the habitual spelling of cell_styles (recurring
|
||||
// server-side 900015206 in eval traces) — rewrite when unambiguous.
|
||||
if styleObj, isObj := cell["style"].(map[string]interface{}); isObj {
|
||||
if _, has := cell["cell_styles"]; has {
|
||||
return common.ValidationErrorf("%s[%d][%d].style conflicts with cell_styles; pass only cell_styles", path, r, c)
|
||||
}
|
||||
cell["cell_styles"] = styleObj
|
||||
delete(cell, "style")
|
||||
}
|
||||
// cells[][].type is not a cell field; the value type is whatever the
|
||||
// JSON value is. Reject with the fix instead of a server round trip.
|
||||
if _, has := cell["type"]; has {
|
||||
return common.ValidationErrorf("%s[%d][%d].type is not a cell field — the value type is inferred from the JSON value; control display format via cell_styles.number_format", path, r, c)
|
||||
}
|
||||
if bs, ok := cell["border_styles"].(map[string]interface{}); ok {
|
||||
expandBorderAllShorthand(bs)
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, misNested := st["border_styles"]; misNested {
|
||||
return common.ValidationErrorf(
|
||||
"%s[%d][%d].cell_styles.border_styles is not valid — border_styles is a top-level cell field, a sibling of cell_styles; move it up one level",
|
||||
path, r, c)
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandBorderAllShorthand rewrites the "all" side shorthand — habitual from
|
||||
// Excel / openpyxl vocabulary, rejected by the backend — into the four
|
||||
// explicit sides, in place. An explicitly set side wins over the shorthand.
|
||||
// Applied on both the typed --cells path and the --styles path, so batch
|
||||
// sub-ops get the same rewrite as standalone calls.
|
||||
func expandBorderAllShorthand(border map[string]interface{}) {
|
||||
if all, ok := border["all"]; ok {
|
||||
for _, side := range []string{"top", "bottom", "left", "right"} {
|
||||
if _, exists := border[side]; !exists {
|
||||
border[side] = all
|
||||
}
|
||||
}
|
||||
delete(border, "all")
|
||||
}
|
||||
// Weight vocabulary in the style slot ("thin"/"medium"/"thick" are the
|
||||
// habitual Excel words; the largest residual styles cluster in the 07-21
|
||||
// rerun wrote them into border_styles.<side>.style of the FULL nested
|
||||
// form). A thin border always means a thin solid line: move the word to
|
||||
// weight and default style to solid. Only when weight is absent — an
|
||||
// explicit conflicting weight keeps the enum error path.
|
||||
for _, raw := range border {
|
||||
side, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, _ := side["style"].(string)
|
||||
switch strings.ToLower(s) {
|
||||
case "thin", "medium", "thick":
|
||||
if _, hasWeight := side["weight"]; !hasWeight {
|
||||
side["weight"] = strings.ToLower(s)
|
||||
side["style"] = "solid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBorderStylesFlagValue runs the border vocabulary rewrites on the
|
||||
// parsed --border-styles value BEFORE schema validation (jsonFlagNormalizers
|
||||
// seam in parseJSONFlag). Without it the enum check fires first and rejects
|
||||
// the weight-word-in-style habit ({"style":"thin"}) that
|
||||
// expandBorderAllShorthand exists to absorb — the acceptance layer was
|
||||
// unreachable on this path (07-28 root-cause report #2, 173 occurrences).
|
||||
// Non-object shapes pass through for the validator to prescribe.
|
||||
func normalizeBorderStylesFlagValue(v interface{}) interface{} {
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
expandBorderAllShorthand(m)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeCellsFlagValue is the +cells-set --cells pre-validation pipeline:
|
||||
// wrap a lone cell object into [[cell]], then run the border vocabulary
|
||||
// rewrites on each cell's border_styles so weight words in the style slot
|
||||
// normalize before the enum check — same reachability fix as
|
||||
// normalizeBorderStylesFlagValue, for the typed-cells carrier (07-28
|
||||
// root-cause report #10, 58 occurrences). Structure is checked leniently:
|
||||
// anything that isn't the expected shape is left for the validator.
|
||||
func normalizeCellsFlagValue(v interface{}) interface{} {
|
||||
v = wrapLoneCellObject(v)
|
||||
rows, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
for _, rowRaw := range rows {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if bs, ok := cell["border_styles"].(map[string]interface{}); ok {
|
||||
expandBorderAllShorthand(bs)
|
||||
}
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects), expanding the "all" side shorthand the
|
||||
// same as the typed --cells and --styles paths so +cells-set-style /
|
||||
// +cells-batch-set-style don't ship {"all":…} for the backend to reject.
|
||||
// The expansion normally already ran inside parseJSONFlag (see
|
||||
// normalizeBorderStylesFlagValue); the call here is an idempotent safety net
|
||||
// for entry paths that bypass the normalizer table.
|
||||
// Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
if runtime.Str("border-styles") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "border-styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
|
||||
}
|
||||
expandBorderAllShorthand(m)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// requireAnyStyleFlag ensures at least one style-defining flag (style or
|
||||
// border) is set — otherwise the request would do nothing.
|
||||
func requireAnyStyleFlag(runtime flagView) error {
|
||||
if len(buildCellStyleFromFlags(runtime)) > 0 {
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
|
||||
WithParams(
|
||||
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
|
||||
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
|
||||
)
|
||||
}
|
||||
|
||||
// foldBorderFamilyAliases rewrites the habitual flattened border vocabulary
|
||||
// (Excel / openpyxl conventions) into the canonical nested border_styles
|
||||
// object, in place. 07-20 eval: the border family alone accounted for the
|
||||
// largest --styles error cluster (borders / border / border_bottom /
|
||||
// border_style / border_top_color / …), each burning a full payload retry.
|
||||
// Accepted rewrites, all unambiguous:
|
||||
//
|
||||
// borders / border (object) → border_styles (side-keyed) or border_styles.all (attr-keyed)
|
||||
// border_top|bottom|left|right (object) → border_styles.<side>
|
||||
// border_style|color|weight (scalar) → border_styles.all.<attr>
|
||||
// border_<side>_<style|color|weight> (scalar) → border_styles.<side>.<attr>
|
||||
//
|
||||
// A border_style value from the WEIGHT vocabulary (thin/medium/thick — the
|
||||
// habitual Excel word) sets weight and defaults style to solid: a "thin
|
||||
// border" always means a thin solid line. Conflicts with an explicitly given
|
||||
// border_styles error out instead of picking a side.
|
||||
// Every walk over a set here goes through an ORDERED slice, never a map range:
|
||||
// each branch below can abort with an error, so map iteration order would
|
||||
// decide which of several bad fields gets reported and the same payload would
|
||||
// produce different messages run to run (same reason parseWorkbookCreateFreezeOp
|
||||
// sorts its keys).
|
||||
func foldBorderFamilyAliases(in map[string]interface{}, path string) error {
|
||||
attrNames := []string{"color", "style", "weight"}
|
||||
sides := map[string]bool{"top": true, "bottom": true, "left": true, "right": true, "all": true}
|
||||
attrs := map[string]bool{"style": true, "color": true, "weight": true}
|
||||
borderWeights := map[string]bool{"thin": true, "medium": true, "thick": true}
|
||||
|
||||
ensureBorder := func() map[string]interface{} {
|
||||
bs, ok := in["border_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
bs = map[string]interface{}{}
|
||||
in["border_styles"] = bs
|
||||
}
|
||||
return bs
|
||||
}
|
||||
setSideAttr := func(side, attr string, v interface{}, from string) error {
|
||||
bs := ensureBorder()
|
||||
sideObj, ok := bs[side].(map[string]interface{})
|
||||
if !ok {
|
||||
if _, exists := bs[side]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with border_styles.%s; keep one form", path, from, side)
|
||||
}
|
||||
sideObj = map[string]interface{}{}
|
||||
bs[side] = sideObj
|
||||
}
|
||||
if _, exists := sideObj[attr]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with border_styles.%s.%s; keep one form", path, from, side, attr)
|
||||
}
|
||||
sideObj[attr] = v
|
||||
return nil
|
||||
}
|
||||
setSide := func(side string, v interface{}, from string) error {
|
||||
obj, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.ValidationErrorf("%s.%s must be an object like {\"style\":\"solid\",\"color\":\"#000000\"}", path, from)
|
||||
}
|
||||
for _, attr := range sortedKeys(obj) {
|
||||
if !attrs[attr] {
|
||||
return common.ValidationErrorf("%s.%s.%s is not a border attribute (want style/weight/color)", path, from, attr)
|
||||
}
|
||||
if err := setSideAttr(side, attr, obj[attr], from); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// border_style with a weight-vocabulary value means "thin solid line".
|
||||
setAllScalar := func(attr string, v interface{}, from string) error {
|
||||
if attr == "style" {
|
||||
if s, ok := v.(string); ok && borderWeights[strings.ToLower(s)] {
|
||||
if err := setSideAttr("all", "weight", strings.ToLower(s), from); err != nil {
|
||||
return err
|
||||
}
|
||||
return setSideAttr("all", "style", "solid", from)
|
||||
}
|
||||
}
|
||||
return setSideAttr("all", attr, v, from)
|
||||
}
|
||||
|
||||
for _, key := range []string{"borders", "border"} {
|
||||
v, has := in[key]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
obj, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return common.ValidationErrorf("%s.%s must be an object — either side-keyed ({\"top\":{…},\"bottom\":{…}} / {\"all\":{…}}) or attribute-keyed ({\"style\":\"solid\",\"color\":\"#000\"} = all four sides)", path, key)
|
||||
}
|
||||
sideKeyed := false
|
||||
for k := range obj {
|
||||
if sides[k] {
|
||||
sideKeyed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if sideKeyed {
|
||||
for _, side := range sortedKeys(obj) {
|
||||
if !sides[side] {
|
||||
return common.ValidationErrorf("%s.%s.%s is not a valid side (want top/bottom/left/right/all)", path, key, side)
|
||||
}
|
||||
if err := setSide(side, obj[side], key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if err := setSide("all", v, key); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(in, key)
|
||||
}
|
||||
for _, side := range []string{"top", "bottom", "left", "right"} {
|
||||
// Both word orders appear in the wild: border_bottom (07-20 eval) and
|
||||
// bottom_border (07-21), same for the flattened attribute triples.
|
||||
for _, key := range []string{"border_" + side, side + "_border"} {
|
||||
if v, has := in[key]; has {
|
||||
if err := setSide(side, v, key); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(in, key)
|
||||
}
|
||||
}
|
||||
for _, attr := range attrNames {
|
||||
for _, key := range []string{"border_" + side + "_" + attr, side + "_border_" + attr} {
|
||||
if v, has := in[key]; has {
|
||||
if err := setSideAttr(side, attr, v, key); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(in, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, attr := range attrNames {
|
||||
key := "border_" + attr
|
||||
if v, has := in[key]; has {
|
||||
if err := setAllScalar(attr, v, key); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(in, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
583
shortcuts/sheets/styles_acceptance_test.go
Normal file
583
shortcuts/sheets/styles_acceptance_test.go
Normal file
@@ -0,0 +1,583 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── styles acceptance contract ───────────────────────────────────────
|
||||
//
|
||||
// Two closure properties that turn the --styles acceptance surface from
|
||||
// "endless patching" into a locked contract (07-20 rerun lesson: the
|
||||
// redesign moved traffic onto the payload path while the flag path's
|
||||
// forgiveness layers stayed behind):
|
||||
//
|
||||
// 1. Vocabulary parity — every style the flag path (+cells-set-style)
|
||||
// can express must be accepted verbatim by the payload path.
|
||||
// 2. Prior corpus — every model spelling observed in eval traces must
|
||||
// either normalize to the canonical form or produce a targeted
|
||||
// prescription. Silent ignoring and bare rejection are both bugs.
|
||||
// New eval finding → add a corpus row → fix → locked forever.
|
||||
|
||||
// acceptStyleItem runs one cell_styles item through the styles-put pipeline
|
||||
// and returns the emitted cell prototype (cell_styles/border_styles) or the
|
||||
// error.
|
||||
func acceptStyleItem(t *testing.T, fields map[string]interface{}) (map[string]interface{}, error) {
|
||||
t.Helper()
|
||||
item := map[string]interface{}{"range": "A1:B2"}
|
||||
for k, v := range fields {
|
||||
item[k] = v
|
||||
}
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
"cell_styles": []interface{}{item},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
cells := input["cells"].([][]interface{})
|
||||
return cells[0][0].(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
// TestStylesAcceptance_VocabularyParity locks property 1: iterate the
|
||||
// +cells-set-style flag vocabulary from flag-defs and assert the payload
|
||||
// path accepts each field with a valid value and emits it.
|
||||
func TestStylesAcceptance_VocabularyParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagDefs: %v", err)
|
||||
}
|
||||
spec, ok := defs["+cells-set-style"]
|
||||
if !ok {
|
||||
t.Fatal("no +cells-set-style flag defs")
|
||||
}
|
||||
sample := func(df flagDef) interface{} {
|
||||
if len(df.Enum) > 0 {
|
||||
return df.Enum[0]
|
||||
}
|
||||
switch df.Type {
|
||||
case "float64", "int":
|
||||
return float64(12)
|
||||
}
|
||||
switch df.Name {
|
||||
case "font-family":
|
||||
return "Arial"
|
||||
case "number-format":
|
||||
return "0.00"
|
||||
default: // colors and any future string field
|
||||
return "#112233"
|
||||
}
|
||||
}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "own" || df.Name == "range" {
|
||||
continue
|
||||
}
|
||||
t.Run(df.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
field := strings.ReplaceAll(df.Name, "-", "_")
|
||||
var value interface{}
|
||||
if df.Name == "border-styles" {
|
||||
value = map[string]interface{}{"all": map[string]interface{}{"style": "solid"}}
|
||||
} else {
|
||||
value = sample(df)
|
||||
}
|
||||
proto, err := acceptStyleItem(t, map[string]interface{}{field: value})
|
||||
if err != nil {
|
||||
t.Fatalf("payload path rejects flag-path field %s: %v", field, err)
|
||||
}
|
||||
if df.Name == "border-styles" {
|
||||
if _, ok := proto["border_styles"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("border_styles not emitted: %v", proto)
|
||||
}
|
||||
return
|
||||
}
|
||||
cs, _ := proto["cell_styles"].(map[string]interface{})
|
||||
if cs == nil || cs[field] == nil {
|
||||
t.Fatalf("field %s silently dropped: %v", field, proto)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stylesPriorCorpus is the observed-model-spelling corpus (source: eval
|
||||
// batches 2026-07-08 → 07-20). Every row must either normalize (checked via
|
||||
// wantCell) or produce a targeted prescription (wantErr). Add a row for every
|
||||
// new spelling an eval surfaces — never let one be silently ignored.
|
||||
var stylesPriorCorpus = []struct {
|
||||
name string
|
||||
fields map[string]interface{}
|
||||
wantErr string // "" = must be accepted
|
||||
check func(proto map[string]interface{}) string // "" = ok, else failure detail
|
||||
}{
|
||||
// border family (07-20: largest cluster)
|
||||
{name: "borders attr-keyed means all sides",
|
||||
fields: map[string]interface{}{"borders": map[string]interface{}{"style": "solid", "color": "#DDDDDD"}},
|
||||
check: wantBorder("top", "style", "solid")},
|
||||
{name: "border side-keyed",
|
||||
fields: map[string]interface{}{"border": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}}},
|
||||
check: wantBorder("top", "style", "solid")},
|
||||
{name: "border_bottom object",
|
||||
fields: map[string]interface{}{"border_bottom": map[string]interface{}{"style": "solid"}},
|
||||
check: wantBorder("bottom", "style", "solid")},
|
||||
{name: "border_style weight-vocabulary means thin solid",
|
||||
fields: map[string]interface{}{"border_style": "thin"},
|
||||
check: wantBorder("top", "weight", "thin")},
|
||||
{name: "border_style style-vocabulary",
|
||||
fields: map[string]interface{}{"border_style": "dashed"},
|
||||
check: wantBorder("top", "style", "dashed")},
|
||||
{name: "border_color scalar",
|
||||
fields: map[string]interface{}{"border_color": "#FF0000"},
|
||||
check: wantBorder("top", "color", "#FF0000")},
|
||||
{name: "border_top_color flattened",
|
||||
fields: map[string]interface{}{"border_top_color": "#FF0000"},
|
||||
check: wantBorder("top", "color", "#FF0000")},
|
||||
{name: "border_left_weight flattened",
|
||||
fields: map[string]interface{}{"border_left_weight": "thin"},
|
||||
check: wantBorder("left", "weight", "thin")},
|
||||
{name: "border_styles invalid side prescribed",
|
||||
fields: map[string]interface{}{"border_styles": map[string]interface{}{"outer": map[string]interface{}{"style": "solid"}}},
|
||||
wantErr: "not a valid side"},
|
||||
// wrap family
|
||||
{name: "wrap_text boolean", fields: map[string]interface{}{"wrap_text": true}, check: wantStyle("word_wrap", "auto-wrap")},
|
||||
{name: "text_wrap string", fields: map[string]interface{}{"text_wrap": "auto-wrap"}, check: wantStyle("word_wrap", "auto-wrap")},
|
||||
{name: "word_wrap false", fields: map[string]interface{}{"word_wrap": false}, check: wantStyle("word_wrap", "overflow")},
|
||||
// alignment family
|
||||
{name: "horizontal_align shorthand", fields: map[string]interface{}{"horizontal_align": "center"}, check: wantStyle("horizontal_alignment", "center")},
|
||||
{name: "valign shorthand", fields: map[string]interface{}{"valign": "top"}, check: wantStyle("vertical_alignment", "top")},
|
||||
{name: "CSS center for vertical", fields: map[string]interface{}{"vertical_alignment": "center"}, check: wantStyle("vertical_alignment", "middle")},
|
||||
{name: "casing normalized", fields: map[string]interface{}{"font_weight": "BOLD"}, check: wantStyle("font_weight", "bold")},
|
||||
// weight vocabulary in the FULL nested form's style slot (07-21 rerun:
|
||||
// the dominant residual — 8 tasks wrote border_styles.<side>.style:"thin")
|
||||
{name: "full-form thin in style slot",
|
||||
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "thin"}}},
|
||||
check: wantBorder("top", "weight", "thin")},
|
||||
{name: "full-form all-shorthand medium in style slot",
|
||||
fields: map[string]interface{}{"border_styles": map[string]interface{}{"all": map[string]interface{}{"style": "medium"}}},
|
||||
check: wantBorder("bottom", "weight", "medium")},
|
||||
// side-first word order + Google Sheets wrap word (07-21 evening batch)
|
||||
{name: "side-first bottom_border object",
|
||||
fields: map[string]interface{}{"bottom_border": map[string]interface{}{"style": "solid"}},
|
||||
check: wantBorder("bottom", "style", "solid")},
|
||||
{name: "side-first bottom_border_style scalar",
|
||||
fields: map[string]interface{}{"bottom_border_style": "solid"},
|
||||
check: wantBorder("bottom", "style", "solid")},
|
||||
{name: "wrap_strategy aliases to word_wrap",
|
||||
fields: map[string]interface{}{"wrap_strategy": "auto-wrap"},
|
||||
check: wantStyle("word_wrap", "auto-wrap")},
|
||||
// prescriptions (ambiguous / unsupported / typo)
|
||||
{name: "fore_color prescribed", fields: map[string]interface{}{"fore_color": "#F00"}, wantErr: "ambiguous"},
|
||||
{name: "indent rejected not ignored", fields: map[string]interface{}{"indent": float64(2)}, wantErr: "not a supported style field"},
|
||||
{name: "unknown field carries did-you-mean and the field list",
|
||||
fields: map[string]interface{}{"fontcolor": "#000000"}, wantErr: `did you mean "font_color"`},
|
||||
{name: "enum typo gets did-you-mean", fields: map[string]interface{}{"vertical_alignment": "botom"}, wantErr: "did you mean"},
|
||||
}
|
||||
|
||||
func wantStyle(field, want string) func(map[string]interface{}) string {
|
||||
return func(proto map[string]interface{}) string {
|
||||
cs, _ := proto["cell_styles"].(map[string]interface{})
|
||||
if cs == nil || cs[field] != want {
|
||||
return fmt.Sprintf("cell_styles.%s = %v, want %q", field, cs[field], want)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func wantBorder(side, attr, want string) func(map[string]interface{}) string {
|
||||
return func(proto map[string]interface{}) string {
|
||||
bs, _ := proto["border_styles"].(map[string]interface{})
|
||||
sideObj, _ := bs[side].(map[string]interface{})
|
||||
if sideObj == nil || sideObj[attr] != want {
|
||||
return fmt.Sprintf("border_styles.%s.%s = %v, want %q", side, attr, sideObj[attr], want)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func TestStylesAcceptance_PriorCorpus(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range stylesPriorCorpus {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
proto, err := acceptStyleItem(t, tc.fields)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("want prescription containing %q, got err=%v", tc.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("corpus spelling rejected: %v", err)
|
||||
}
|
||||
if detail := tc.check(proto); detail != "" {
|
||||
t.Fatal(detail)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStylesPut_CoalescesSameStyleRanges pins the declarative-spec
|
||||
// optimization: per-row entries with the identical style fuse into one
|
||||
// rectangle, so row-by-row specs (07-21 rerun: 184/203/861-op expansions
|
||||
// against the 100-op cap) no longer hit the cap.
|
||||
func TestStylesPut_CoalescesSameStyleRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("150 same-style rows fuse into one stamp", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries := make([]interface{}, 0, 150)
|
||||
for r := 1; r <= 150; r++ {
|
||||
entries = append(entries, map[string]interface{}{
|
||||
"range": fmt.Sprintf("A%d:F%d", r, r), "font_weight": "bold",
|
||||
})
|
||||
}
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": entries}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("got %d ops, want 1 fused stamp", len(ops))
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:F150" {
|
||||
t.Fatalf("range = %v, want A1:F150", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different styles stay separate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
||||
map[string]interface{}{"range": "A1:F1", "font_weight": "bold"},
|
||||
map[string]interface{}{"range": "A2:F2", "background_color": "#EEEEEE"},
|
||||
}}},
|
||||
}), testToken)
|
||||
if err != nil || len(ops) != 2 {
|
||||
t.Fatalf("ops=%d err=%v, want 2", len(ops), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("horizontal fuse with same rows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
||||
map[string]interface{}{"range": "A1:C5", "font_weight": "bold"},
|
||||
map[string]interface{}{"range": "D1:F5", "font_weight": "bold"},
|
||||
}}},
|
||||
}), testToken)
|
||||
if err != nil || len(ops) != 1 {
|
||||
t.Fatalf("ops=%d err=%v, want 1", len(ops), err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["range"] != "A1:F5" {
|
||||
t.Fatalf("range = %v, want A1:F5", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
// The cases above all pin that adjacent ranges DO fuse. The dangerous
|
||||
// direction is the other one: coalescing rewrites a declarative spec into
|
||||
// bigger rectangles, so a too-generous adjacency rule would paint cells the
|
||||
// caller never named — silently, and only visible in the finished sheet.
|
||||
// Widening the `+1` touch test in union() to `+2` passes every test above.
|
||||
t.Run("a one-row gap is not fused across", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
||||
map[string]interface{}{"range": "A1:C1", "font_weight": "bold"},
|
||||
map[string]interface{}{"range": "A3:C3", "font_weight": "bold"},
|
||||
}}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("ops=%d, want 2 — row 2 was never named and must not be styled", len(ops))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a one-column gap is not fused across", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
|
||||
map[string]interface{}{"range": "A1:B5", "font_weight": "bold"},
|
||||
map[string]interface{}{"range": "D1:E5", "font_weight": "bold"},
|
||||
}}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("ops=%d, want 2 — column C was never named and must not be styled", len(ops))
|
||||
}
|
||||
})
|
||||
|
||||
// The general property behind both: whatever coalescing does to the shape
|
||||
// of the stamps, the SET of cells it covers must be exactly the set the
|
||||
// caller named. Checked over a mix of touching, overlapping and separated
|
||||
// rectangles so it constrains the merge rule rather than one example.
|
||||
t.Run("coverage is preserved exactly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
inputs := []string{
|
||||
"A1:C1", "A2:C2", // touching vertically -> may fuse
|
||||
"E1:F2", "E3:F4", // touching vertically, different block
|
||||
"A5:C5", // separated from A2:C2 by row 3-4 in columns A-C
|
||||
"B2:D3", // overlaps the first block
|
||||
"H10:H10",
|
||||
}
|
||||
entries := make([]interface{}, 0, len(inputs))
|
||||
for _, r := range inputs {
|
||||
entries = append(entries, map[string]interface{}{"range": r, "font_weight": "bold"})
|
||||
}
|
||||
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": entries}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[[2]int]bool{}
|
||||
for _, r := range inputs {
|
||||
addRangeCells(t, want, r)
|
||||
}
|
||||
got := map[[2]int]bool{}
|
||||
for _, op := range ops {
|
||||
input := op.(map[string]interface{})["input"].(map[string]interface{})
|
||||
addRangeCells(t, got, input["range"].(string))
|
||||
}
|
||||
for cell := range want {
|
||||
if !got[cell] {
|
||||
t.Errorf("cell %v was named but no stamp covers it", cell)
|
||||
}
|
||||
}
|
||||
for cell := range got {
|
||||
if !want[cell] {
|
||||
t.Errorf("cell %v is stamped but was never named by the caller", cell)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// addRangeCells records every (col,row) an A1 rectangle covers, so a test can
|
||||
// compare what a spec named against what the expanded stamps actually touch.
|
||||
func addRangeCells(t *testing.T, set map[[2]int]bool, rangeStr string) {
|
||||
t.Helper()
|
||||
c1, r1, c2, r2, err := workbookCreateStyleRangeBounds(rangeStr)
|
||||
if err != nil {
|
||||
t.Fatalf("bad range %q in test data: %v", rangeStr, err)
|
||||
}
|
||||
for c := c1; c <= c2; c++ {
|
||||
for r := r1; r <= r2; r++ {
|
||||
set[[2]int{c, r}] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedCellsHabitualKeys pins the typed --cells cell-object fixes
|
||||
// (recurring server-side 900015206 across 07-20/07-21 reruns).
|
||||
func TestTypedCellsHabitualKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("style object rewrites to cell_styles through batch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
translated, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{
|
||||
"value": "x", "style": map[string]interface{}{"font_weight": "bold"},
|
||||
}}},
|
||||
}), testToken, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := translated["input"].(map[string]interface{})
|
||||
cell := input["cells"].([]interface{})[0].([]interface{})[0].(map[string]interface{})
|
||||
cs, _ := cell["cell_styles"].(map[string]interface{})
|
||||
if cs == nil || cs["font_weight"] != "bold" {
|
||||
t.Fatalf("cell = %v, want cell_styles.font_weight bold", cell)
|
||||
}
|
||||
if _, has := cell["style"]; has {
|
||||
t.Fatalf("style key must be renamed, got %v", cell)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type key gets a prescription", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
|
||||
"sheet_name": "S1", "range": "A1",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{
|
||||
"value": "x", "type": "text",
|
||||
}}},
|
||||
}), testToken, 0)
|
||||
requireValidation(t, err, "not a cell field")
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesAcceptance_ResizeAndMergeCorpus extends the corpus to the
|
||||
// row/col_sizes and cell_merges sections.
|
||||
func TestStylesAcceptance_ResizeAndMergeCorpus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runSection := func(section string, entry interface{}) ([]interface{}, error) {
|
||||
return stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "S1",
|
||||
section: []interface{}{entry},
|
||||
}},
|
||||
}), testToken)
|
||||
}
|
||||
pixelValue := func(t *testing.T, ops []interface{}, key string) interface{} {
|
||||
t.Helper()
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
block, _ := input[key].(map[string]interface{})
|
||||
if block == nil || block["type"] != "pixel" {
|
||||
t.Fatalf("%s = %v, want pixel block", key, input[key])
|
||||
}
|
||||
return block["value"]
|
||||
}
|
||||
|
||||
t.Run("size alone implies pixel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := runSection("row_sizes", map[string]interface{}{"range": "1:1", "size": float64(36)})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v := pixelValue(t, ops, "resize_height"); v != 36 {
|
||||
t.Fatalf("value = %v, want 36", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("width alone implies pixel on col_sizes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := runSection("col_sizes", map[string]interface{}{"range": "A:C", "width": float64(120)})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v := pixelValue(t, ops, "resize_width"); v != 120 {
|
||||
t.Fatalf("value = %v, want 120", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type auto still works on rows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := runSection("row_sizes", map[string]interface{}{"range": "1:1", "type": "auto"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("neither size nor type prescribed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := runSection("row_sizes", map[string]interface{}{"range": "1:1"})
|
||||
requireValidation(t, err, "needs size (px) or type")
|
||||
})
|
||||
|
||||
t.Run("wrong-dimension word prescribed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := runSection("col_sizes", map[string]interface{}{"range": "A:C", "height": float64(36)})
|
||||
requireValidation(t, err, "does not apply")
|
||||
})
|
||||
|
||||
t.Run("raw OpenAPI merge_type accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ops, err := runSection("cell_merges", map[string]interface{}{"range": "A1:B2", "merge_type": "MERGE_ALL"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if input["merge_type"] != "all" {
|
||||
t.Fatalf("merge_type = %v, want all", input["merge_type"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare string merge accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := runSection("cell_merges", "A1:B2"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesAcceptance_FlagPathParity is property 1's missing half.
|
||||
//
|
||||
// TestStylesAcceptance_VocabularyParity walks the same flag-defs vocabulary but
|
||||
// exercises the PAYLOAD path (--styles items). The FLAG path — the flat
|
||||
// --font-color / --number-format / … flags on +cells-set-style — is a separate
|
||||
// hand-written mapping in buildCellStyleFromFlags, and a coverage run showed
|
||||
// six of its eleven branches never executed by any test. A typo there (writing
|
||||
// the wrong wire key, or reading the wrong flag) silently drops a style the
|
||||
// caller explicitly asked for: the request still succeeds, the sheet just does
|
||||
// not change. Derived from flag-defs so a new style flag is covered the moment
|
||||
// it is declared.
|
||||
func TestStylesAcceptance_FlagPathParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagDefs: %v", err)
|
||||
}
|
||||
spec, ok := defs["+cells-set-style"]
|
||||
if !ok {
|
||||
t.Fatal("no +cells-set-style flag defs")
|
||||
}
|
||||
|
||||
args := []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}
|
||||
want := map[string]interface{}{}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "own" || df.Name == "range" || df.Name == "border-styles" {
|
||||
continue // border-styles is a composite with its own structural tests
|
||||
}
|
||||
field := strings.ReplaceAll(df.Name, "-", "_")
|
||||
var value string
|
||||
switch {
|
||||
case len(df.Enum) > 0:
|
||||
value = df.Enum[0]
|
||||
want[field] = value
|
||||
case df.Type == "float64" || df.Type == "int":
|
||||
value = "14"
|
||||
want[field] = float64(14)
|
||||
case df.Name == "font-family":
|
||||
value, want[field] = "Arial", "Arial"
|
||||
case df.Name == "number-format":
|
||||
value, want[field] = "0.00", "0.00"
|
||||
default:
|
||||
value, want[field] = "#112233", "#112233"
|
||||
}
|
||||
args = append(args, "--"+df.Name, value)
|
||||
}
|
||||
if len(want) < 5 {
|
||||
t.Fatalf("expected the flat style vocabulary, only built %d fields", len(want))
|
||||
}
|
||||
|
||||
input := decodeToolInput(t, parseDryRunBody(t, CellsSetStyle, args), "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) == 0 {
|
||||
t.Fatalf("no cells in %v", input)
|
||||
}
|
||||
row, _ := cells[0].([]interface{})
|
||||
cell, _ := row[0].(map[string]interface{})
|
||||
got, _ := cell["cell_styles"].(map[string]interface{})
|
||||
if got == nil {
|
||||
t.Fatalf("no cell_styles emitted: %v", cell)
|
||||
}
|
||||
for field, expected := range want {
|
||||
actual, present := got[field]
|
||||
if !present {
|
||||
t.Errorf("flag --%s produced no %q on the wire — the style is silently dropped",
|
||||
strings.ReplaceAll(field, "_", "-"), field)
|
||||
continue
|
||||
}
|
||||
if actual != expected {
|
||||
t.Errorf("%s = %#v, want %#v", field, actual, expected)
|
||||
}
|
||||
}
|
||||
for field := range got {
|
||||
if _, expected := want[field]; !expected {
|
||||
t.Errorf("unexpected wire field %q emitted by the flag path", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
515
shortcuts/sheets/styles_prescription_test.go
Normal file
515
shortcuts/sheets/styles_prescription_test.go
Normal file
@@ -0,0 +1,515 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTablePut_StylesErrorsAggregate pins the one-retry contract for
|
||||
// --styles: every issue across sections and ops is reported in a single
|
||||
// error (eval V2U032 burned three round trips fixing a border side, then
|
||||
// row_sizes.type, then size — each surfaced only after the previous fix).
|
||||
func TestTablePut_StylesErrorsAggregate(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"s",
|
||||
"cell_styles":[{"range":"A1:A1","border_styles":{"horizontal":{"style":"solid"}}}],
|
||||
"row_sizes":[{"range":"1:1","type":"custom"}],
|
||||
"col_sizes":[{"range":"A:A","type":"pixel"}]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "--styles has 3 issues")
|
||||
for _, want := range []string{
|
||||
"border_styles.horizontal is not a valid side",
|
||||
`row_sizes[0].type "custom" is invalid`,
|
||||
"col_sizes[0].type pixel requires size",
|
||||
} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Errorf("aggregated message should contain %q, got %q", want, ve.Message)
|
||||
}
|
||||
}
|
||||
// D2: each type/size error inlines a complete valid op.
|
||||
if !strings.Contains(ve.Message, `{"range":"2:10","type":"pixel","size":32}`) {
|
||||
t.Errorf("row_sizes error should inline a full valid example, got %q", ve.Message)
|
||||
}
|
||||
if !strings.Contains(ve.Message, `{"range":"A:C","type":"pixel","size":120}`) {
|
||||
t.Errorf("col_sizes error should inline a full valid example, got %q", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_StylesFieldPrescriptions pins the curated fixes for the
|
||||
// high-frequency unsupported cell_styles field names, and the near-typo
|
||||
// guard on the did-you-mean fallback (07-28 root-cause report #14/#21/#27:
|
||||
// font_bold used to draw "did you mean font_color?" and nested font drew
|
||||
// "font_line" — concept-swap neighbors that mislead worse than silence).
|
||||
func TestTablePut_StylesFieldPrescriptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
field string // JSON fragment inside the cell_styles item
|
||||
want []string
|
||||
notSuggest []string // must NOT appear as a did-you-mean
|
||||
}{
|
||||
{"bold", `"bold":true`, []string{`font_weight:"bold"`}, nil},
|
||||
{"font_bold", `"font_bold":true`, []string{`font_weight:"bold"`}, []string{"font_color"}},
|
||||
{"text_align", `"text_align":"center"`, []string{"horizontal_alignment"}, nil},
|
||||
{"nested font", `"font":{"bold":true,"size":18}`, []string{"flat font_*", `font_weight:"bold"`}, []string{"font_line"}},
|
||||
{"near-typo still suggests", `"font_colour":"#FFF"`, []string{`did you mean "font_color"`}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"s","cell_styles":[{"range":"A1:A1",` + tc.field + `}]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "is not a supported style field")
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Errorf("message should contain %q, got %q", want, ve.Message)
|
||||
}
|
||||
}
|
||||
for _, bad := range tc.notSuggest {
|
||||
if strings.Contains(ve.Message, `did you mean "`+bad+`"`) {
|
||||
t.Errorf("message must not suggest %q, got %q", bad, ve.Message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTablePut_StylesBorderAllExpands verifies the "all" shorthand is
|
||||
// rewritten to four explicit sides instead of being rejected (or worse,
|
||||
// passed through for the server to reject, as happened on the typed-cells
|
||||
// path in eval V2U013/V2U021).
|
||||
func TestTablePut_StylesBorderAllExpands(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+table-put")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"s","cell_styles":[{"range":"A1:A1","border_styles":{"all":{"style":"solid","weight":"thin"}}}]}]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
|
||||
}
|
||||
// table-put's dry-run body carries the tool input as an escaped JSON
|
||||
// string, so match the escaped key form.
|
||||
for _, side := range []string{`\"top\"`, `\"bottom\"`, `\"left\"`, `\"right\"`} {
|
||||
if !strings.Contains(stdout, side) {
|
||||
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, `\"all\"`) {
|
||||
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_BorderAllAndMisNestedBorder covers the typed --cells path:
|
||||
// the "all" shorthand expands CLI-side, and border_styles mis-nested inside
|
||||
// cell_styles is intercepted with a move-it prescription instead of a
|
||||
// server-side 900015206.
|
||||
func TestCellsSet_BorderAllAndMisNestedBorder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("border all expands", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":"x","border_styles":{"all":{"style":"solid"}}}]]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand and pass, got: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, `"all"`) || !strings.Contains(stdout, `"top"`) {
|
||||
t.Errorf("dry-run body should carry expanded sides, got %q", stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mis-nested border_styles intercepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":"x","cell_styles":{"font_weight":"bold","border_styles":{"top":{"style":"solid"}}}}]]`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "cell_styles.border_styles is not valid")
|
||||
if !strings.Contains(ve.Message, "sibling of cell_styles") {
|
||||
t.Errorf("message should prescribe moving it up one level, got %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_BorderAllExpands covers the --border-styles flag path
|
||||
// (+cells-set-style / +cells-batch-set-style go through borderStylesFromFlag,
|
||||
// not the typed --cells or --styles walkers): the "all" shorthand must expand
|
||||
// CLI-side here too, or the backend rejects {"all":…}.
|
||||
func TestCellsSetStyle_BorderAllExpands(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--border-styles", `{"all":{"style":"solid","weight":"thin"}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
|
||||
}
|
||||
for _, side := range []string{`"top"`, `"bottom"`, `"left"`, `"right"`} {
|
||||
if !strings.Contains(stdout, side) {
|
||||
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, `"all"`) {
|
||||
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsMerge_RawAPIVocabularyNormalizes pins MERGE_ALL → all (the raw
|
||||
// OpenAPI enum agents copy from Lark API docs) via the enum alias table.
|
||||
func TestCellsMerge_RawAPIVocabularyNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-merge")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:B2",
|
||||
"--merge-type", "MERGE_ALL",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MERGE_ALL should normalize to all and pass, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, `"all"`) {
|
||||
t.Errorf("dry-run body should carry the normalized merge type, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_WordWrapBooleanNormalizes pins --word-wrap true →
|
||||
// auto-wrap (eval V2U029).
|
||||
func TestCellsSetStyle_WordWrapBooleanNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--word-wrap", "true",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--word-wrap true should normalize to auto-wrap, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "auto-wrap") {
|
||||
t.Errorf("dry-run body should carry auto-wrap, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_WordWrapGoogleVocabularyNormalizes pins the Google
|
||||
// Sheets wrapStrategy words (WRAP / CLIP) onto the Lark enum — the flag
|
||||
// name --wrap-strategy already prescribes --word-wrap, so the value
|
||||
// vocabulary has to land too or the retry fails a second time.
|
||||
func TestCellsSetStyle_WordWrapGoogleVocabularyNormalizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
for word, want := range map[string]string{"wrap": "auto-wrap", "WRAP": "auto-wrap", "clip": "word-clip"} {
|
||||
t.Run(word, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--word-wrap", word,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--word-wrap %s should normalize to %s, got: %v", word, want, err)
|
||||
}
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("dry-run body should carry %s, got %q", want, stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_BorderWeightNumberNamesEnum pins the enum-over-skeleton
|
||||
// rule: a type mismatch on an enum-bearing field answers with the allowed
|
||||
// values, not a whole-payload skeleton ({"bottom": {…}, "left": {…}, …}
|
||||
// told the caller nothing about thin/medium/thick — 07-28 root-cause
|
||||
// report #5, 75 occurrences).
|
||||
func TestCellsSetStyle_BorderWeightNumberNamesEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet-name", "s",
|
||||
"--range", "A1",
|
||||
"--border-styles", `{"top":{"style":"solid","weight":1}}`,
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, `expected type "string", got "number"`)
|
||||
for _, want := range []string{`"thin"`, `"medium"`, `"thick"`} {
|
||||
if !strings.Contains(ve.Message, want) {
|
||||
t.Errorf("message should name the weight enum %s, got %q", want, ve.Message)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ve.Message, "expected shape:") {
|
||||
t.Errorf("enum-bearing mismatch should not fall back to the shape skeleton, got %q", ve.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnderscoreFlagFormsParse pins the wire-vocabulary underscore rewrite:
|
||||
// --sheet_name / --border_styles parse as their hyphen forms (agents copy
|
||||
// field names out of JSON payloads where underscores are canonical).
|
||||
func TestUnderscoreFlagFormsParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := shortcutFromRegistry(t, "+cells-set-style")
|
||||
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--url", testURL,
|
||||
"--sheet_name", "s",
|
||||
"--range", "A1:A1",
|
||||
"--font_weight", "bold",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("underscore flag forms should parse as hyphen forms, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout, "bold") {
|
||||
t.Errorf("dry-run body should carry the style, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_UnderscoreFlagName pins --flag-name border_styles
|
||||
// resolving the border-styles schema (eval V2U013 burned a retry on this).
|
||||
func TestPrintFlagSchema_UnderscoreFlagName(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+cells-set-style")
|
||||
out, err := print("border_styles")
|
||||
if err != nil {
|
||||
t.Fatalf("underscore flag-name should resolve the hyphen schema, got: %v", err)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatal("expected schema output")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_DottedPathSlices pins the schema sub-path slicing
|
||||
// contract on the real embedded chart schema: a dotted --flag-name returns
|
||||
// just that subtree, and a path miss lists the keys actually available.
|
||||
func TestPrintFlagSchema_DottedPathSlices(t *testing.T) {
|
||||
t.Parallel()
|
||||
print := printFlagSchemaFor("+chart-create")
|
||||
|
||||
t.Run("slices a nested subtree", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
out, err := print("properties.snapshot.plotArea.axes")
|
||||
if err != nil {
|
||||
t.Fatalf("dotted path should slice, got: %v", err)
|
||||
}
|
||||
full, err2 := print("properties")
|
||||
if err2 != nil {
|
||||
t.Fatalf("full dump: %v", err2)
|
||||
}
|
||||
if len(out) == 0 || len(out) >= len(full) {
|
||||
t.Errorf("slice should be non-empty and smaller than the full schema (%d vs %d bytes)", len(out), len(full))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path miss lists available keys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := print("properties.snapshot.nosuchkey")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown path segment")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "available keys:") {
|
||||
t.Errorf("error should list available keys, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStylesFieldTypesValidated pins the type half of style validation: the
|
||||
// --styles payloads skip the generic JSON-schema pass (their schema describes
|
||||
// the outer envelope), so scalar fields are type-checked against flag-defs
|
||||
// here. Without it, {"font_weight": true} reached the server as a boolean.
|
||||
func TestStylesFieldTypesValidated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
field string
|
||||
want string
|
||||
}{
|
||||
{"boolean font_weight", `"font_weight":true`, "font_weight must be a string, got boolean"},
|
||||
{"numeric background_color", `"background_color":123`, "background_color must be a string, got number"},
|
||||
{"string font_size", `"font_size":"12"`, "font_size must be a number, got string"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "s",
|
||||
"cell_styles": []interface{}{mustJSONMap(t, `{"range":"A1",`+tc.field+`}`)},
|
||||
}},
|
||||
}), testToken)
|
||||
requireValidation(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("well-typed fields still pass", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "s",
|
||||
"cell_styles": []interface{}{mustJSONMap(t, `{"range":"A1","font_weight":"bold","font_size":12,"background_color":"#FFFFFF"}`)},
|
||||
}},
|
||||
}), testToken)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for well-typed styles: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAggregatedStyleErrorsCarryTypedParam pins the error contract for the
|
||||
// aggregate path: an agent must be able to read which flag to fix from the
|
||||
// typed envelope, not by parsing the prose message.
|
||||
func TestAggregatedStyleErrorsCarryTypedParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "s",
|
||||
"cell_styles": []interface{}{
|
||||
mustJSONMap(t, `{"range":"A1","font_weight":"heavy"}`),
|
||||
mustJSONMap(t, `{"range":"B1"}`),
|
||||
},
|
||||
}},
|
||||
}), testToken)
|
||||
ve := requireValidation(t, err, "has 2 issues")
|
||||
if ve.Param != "--styles" {
|
||||
t.Errorf("Param = %q, want --styles", ve.Param)
|
||||
}
|
||||
if ve.Cause == nil {
|
||||
t.Error("aggregate error should keep the first underlying error as Cause")
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSONMap(t *testing.T, raw string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatalf("bad test JSON %q: %v", raw, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestFreezeAliasConflictRejected pins determinism: "cols" and "columns" are
|
||||
// aliases for one field, so accepting both would make the frozen column count
|
||||
// depend on Go's randomized map iteration order — the same payload could
|
||||
// freeze 1 column on one run and 2 on the next.
|
||||
func TestFreezeAliasConflictRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("conflicting alias values reject every time", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for i := 0; i < 200; i++ {
|
||||
_, err := parseWorkbookCreateFreezeOp(map[string]interface{}{
|
||||
"cols": float64(1), "columns": float64(2),
|
||||
}, "--styles.styles[0].freeze")
|
||||
if err == nil {
|
||||
t.Fatalf("iteration %d: conflicting cols/columns must be rejected", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("identical alias values pass", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseWorkbookCreateFreezeOp(map[string]interface{}{
|
||||
"cols": float64(2), "columns": float64(2),
|
||||
}, "p")
|
||||
if err != nil || got.Cols != 2 {
|
||||
t.Fatalf("got=%+v err=%v, want Cols=2", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("either alias alone still works", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, key := range []string{"cols", "columns"} {
|
||||
got, err := parseWorkbookCreateFreezeOp(map[string]interface{}{key: float64(3)}, "p")
|
||||
if err != nil || got.Cols != 3 {
|
||||
t.Fatalf("%s: got=%+v err=%v, want Cols=3", key, got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSingleIssueStillAttributesFlag pins that the aggregate entry points
|
||||
// attribute the outer flag even for ONE issue — an agent must read the flag
|
||||
// to fix from the typed envelope, not by parsing a nested path out of prose.
|
||||
func TestSingleIssueStillAttributesFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
|
||||
"styles": []interface{}{map[string]interface{}{
|
||||
"name": "s",
|
||||
"cell_styles": []interface{}{mustJSONMap(t, `{"range":"A1","font_weight":true}`)},
|
||||
}},
|
||||
}), testToken)
|
||||
ve := requireValidation(t, err, "font_weight must be a string")
|
||||
if ve.Param != "--styles" {
|
||||
t.Errorf("Param = %q, want --styles even for a single issue", ve.Param)
|
||||
}
|
||||
if ve.Cause == nil {
|
||||
t.Error("single-issue aggregate should keep the underlying error as Cause")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregatedIssuesKeepPrescriptions pins that folding per-item failures
|
||||
// into one flag error does not drop the Hint the inner error carried. The
|
||||
// prescriptions this domain adds (requireSheetSelector's "+workbook-info"
|
||||
// pointer, for one) live in Hint, and a Problem has exactly one Hint slot —
|
||||
// so a lone issue must inherit it and a folded list must inline each one.
|
||||
func TestAggregatedIssuesKeepPrescriptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("single --writes issue inherits the inner hint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL,
|
||||
"--writes", `[{"range":"A1","cells":[[{"value":1}]]}]`,
|
||||
})
|
||||
ve := requireValidation(t, err, "specify at least one of --sheet-id or --sheet-name")
|
||||
if !strings.Contains(ve.Hint, "+workbook-info") {
|
||||
t.Errorf("the inner prescription must survive the fold, got hint %q", ve.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("folded --writes issues inline each hint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL,
|
||||
"--writes", `[{"range":"A1","cells":[[{"value":1}]]},{"range":"B1","cells":[[{"value":2}]]}]`,
|
||||
})
|
||||
ve := requireValidation(t, err, "--writes has 2 issues")
|
||||
if strings.Count(ve.Message, "+workbook-info") != 2 {
|
||||
t.Errorf("each issue should carry its own prescription inline, got %q", ve.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,7 @@ lark-cli auth login --domain apps
|
||||
| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) |
|
||||
| 逐条执行 SQL(SELECT / DML / DDL);建表 / 改表 / 写 SQL 的平台规范 | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)(含「平台 SQL 规范」:审计列 / RLS / `user_profile` / 禁用 SQL / PG 陷阱) |
|
||||
| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) |
|
||||
| 调试应用运行时缓存:查看/删除单个业务 key、清空指定环境缓存 | `+cache-get`/`+cache-delete`/`+cache-clear` | [`lark-apps-cache.md`](references/lark-apps-cache.md) |
|
||||
| **部署/上线应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | 本地开发链路先按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 确认本次改动已 git commit + git push,再用 `+release-create` / `+release-get`;查历史用 `+release-list` | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
|
||||
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
|
||||
| 创意模式(html)应用的评论相关操作 | 创意模式应用评论走 lark-drive 文档评论体系,读取 [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) 了解评论能力 | [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) |
|
||||
|
||||
61
skills/lark-apps/references/lark-apps-cache.md
Normal file
61
skills/lark-apps/references/lark-apps-cache.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# apps cache 域命令(应用运行时缓存调试)
|
||||
|
||||
调试妙搭应用的运行时缓存:查看某个缓存 key 的内容、删除单个 key、清空某个环境的全部缓存。缓存是应用为了加速而临时存放的数据,删除或清空后,应用下次用到时会自动重新取最新数据。命令事实以 `lark-cli apps +<cmd> --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。
|
||||
|
||||
## 何时用
|
||||
|
||||
用户要排查「某个缓存 key 里存的是什么 / 有没有命中」、想删掉某个 key 让应用下次拿到最新数据、或想清空某个环境的缓存做快速恢复时。
|
||||
|
||||
## 命令一览
|
||||
|
||||
| 命令 | 做什么 | 关键参数 |
|
||||
|---|---|---|
|
||||
| `+cache-get` | 查一个缓存 key 的内容与信息 | `--key`、`--environment`、`--format` |
|
||||
| `+cache-delete` | 删一个缓存 key(重复删不会报错;不需 `--yes`) | `--key`、`--environment` |
|
||||
| `+cache-clear` | 清空指定环境下的全部缓存(**高危**) | `--environment`、`--yes` |
|
||||
|
||||
> 所有命令都需 `--app-id`。
|
||||
|
||||
## 约定(先读)
|
||||
|
||||
- **环境 `--environment dev|online`(可省略)**:缓存按运行环境隔离。不指定时按应用当前的环境配置自动选择——有多环境的应用默认落到开发环境 `dev`,没有多环境的就是线上 `online`;返回结果里的 `environment` 会告诉你这次实际操作的是哪个环境。想固定就显式传。
|
||||
- **缓存 key 用 `--key` 传**:传业务里使用的那个 key;是否合法(非空、长度等)由服务端校验,不合法会返回错误。
|
||||
- **风险分级**:`+cache-clear` 会清掉整个环境的缓存,是高危操作,不带 `--yes` 会被确认关卡拦下;`+cache-delete` 只删单个 key、影响小,不需 `--yes`。
|
||||
- **`+cache-get` 的内容有两种展示**:`--format json`(默认)原样返回缓存内容,适合精确比对;`--format pretty` 会把内容格式化展开,更便于阅读。
|
||||
|
||||
## 各命令
|
||||
|
||||
### +cache-get
|
||||
按 `--key` 查单个缓存。命中时返回:是否存在、剩余有效期(TTL)、内容及其大小;未命中(或已过期)时只返回 `exists=false`、不带内容。
|
||||
|
||||
> 每次查询都会连内容一起返回(没有「只看信息、不取内容」的模式),内容可能较大——只是想确认「在不在 / 还有多久过期」时,留意别占用太多上下文。
|
||||
|
||||
```bash
|
||||
lark-cli apps +cache-get --app-id app_xxx --key spotbonus:2026:winners:list:v1
|
||||
lark-cli apps +cache-get --app-id app_xxx --environment online --key <key> --format pretty
|
||||
```
|
||||
|
||||
### +cache-delete
|
||||
删一个缓存 key。**重复删、或删一个本就不存在的 key,都算成功**(返回删除数量 0)、不会报错;删中则返回删除数量 1。删掉后应用下次会自动重新取最新数据,影响小,故不需 `--yes`。
|
||||
|
||||
```bash
|
||||
lark-cli apps +cache-delete --app-id app_xxx --environment dev --key <key>
|
||||
```
|
||||
|
||||
### +cache-clear(高危)
|
||||
清空当前应用在**指定环境**下的全部缓存,用于定位不到具体 key 时的快速恢复。影响面是整个环境,必须带 `--yes`;返回本次清除的 key 数量。动手前可先 `--dry-run` 预览将要执行的操作。
|
||||
|
||||
```bash
|
||||
lark-cli apps +cache-clear --app-id app_xxx --environment dev --yes
|
||||
```
|
||||
|
||||
## 错误与边界
|
||||
|
||||
- **key 不合法 / 缓存服务暂时不可用**:命令会返回带说明的错误,按 `error.hint` 转述给用户;「服务暂时不可用」这类可稍后重试。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- **写操作先定环境**:`+cache-clear` / `+cache-delete` 不指定 `--environment` 时会落到自动选中的环境——**没有多环境的应用会直接作用到线上 `online`(生产)**。不确定应用有没有多环境时,写操作显式传 `--environment`;纯查看(`+cache-get`)影响小,可以省略。
|
||||
- **`+cache-clear` 会清掉整个环境的缓存**:执行前先跟用户确认环境无误、说明会清掉该环境全部缓存。已明确授权可直接带 `--yes`;遇到确认关卡(`confirmation_required`,exit 10)按 lark-shared 约定与用户确认后再补 `--yes` 重试,不要静默追加。
|
||||
- **排查缓存内容优先用 `+cache-get`**:想看结构化、易读的内容用 `--format pretty`;想拿原始内容做精确比对用默认 JSON。
|
||||
- **删 key 前先对齐 key**:用户只描述了业务含义、没给准确 key 时,先确认再删——删错影响也有限(应用会自动重建),但仍应避免误删。
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-base
|
||||
version: 1.2.3
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive,认证/授权转 lark-shared。"
|
||||
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive,认证/授权转 lark-shared。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -23,14 +23,15 @@ metadata:
|
||||
不要使用本 skill:
|
||||
|
||||
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`。
|
||||
- 把本地 Excel / CSV / `.base` 导入成 Base,转 `lark-drive +import --type bitable`。
|
||||
- 把本地文件导入成 Base,或将 Base 导出为本地文件,转 `lark-drive`。
|
||||
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
|
||||
|
||||
## 使用边界
|
||||
|
||||
- Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置,首次请求必须基于可信的当前配置执行 read-modify-write:只修改用户明确指定的内容,保留其他仍适用的可写配置,并按命令要求的结构提交。若命令支持局部/delta update,按其契约提交最小合法 payload;不得以不完整请求试错补参。
|
||||
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
|
||||
- 本地文件与 Base 之间的导入/导出转 `lark-drive`,具体格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;导入完成后再回到 Base 命令。
|
||||
- 在线复制 Base 使用 `+base-copy`,不要绕行导出/导入。
|
||||
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。
|
||||
|
||||
## 先获取 Base Token 和所需 ID
|
||||
@@ -49,6 +50,7 @@ metadata:
|
||||
|---|---|---|
|
||||
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
|
||||
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema;写入后报告新 Base 标识和 `permission_grant` |
|
||||
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
|
||||
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
|
||||
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 |
|
||||
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
|
||||
@@ -63,8 +65,9 @@ metadata:
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | Base 内表单按 table 管理;先确定并复用真实 `table_id`。读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| Base 内表单管理 | `+form-list/get/create/update/delete` / `+form-questions-list/delete` | 缺少或不确定归属时,先用 `+table-list` 或 `+base-block-list` 取得真实 `table_id`;这些命令使用 `--base-token + --table-id` 并在整个工作流中复用同一 `table_id`,删除前确认目标表单 |
|
||||
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
|
||||
@@ -116,6 +119,9 @@ metadata:
|
||||
|
||||
## 表单与视图细节
|
||||
|
||||
- Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list` 或 `+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id`。`+form-detail` 是分享表单入口,标识域不同,只使用 `share_token`。
|
||||
- 表单问题由数据表字段承载,question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。
|
||||
- `+form-questions-delete` 会删除承载问题的数据表字段。主字段问题不可删除;不要把主字段 ID 放入 `--question-ids`,需要修改时使用 `+form-questions-update`。
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-questions-update` 是题目配置全量覆盖,不是 patch;未传字段会回落默认值,传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
|
||||
@@ -137,9 +137,12 @@ lark-cli base +form-questions-create \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 先用 `+form-questions-list` 查看现有问题
|
||||
2. 确认要添加的问题内容
|
||||
3. 执行命令并报告新建的问题 ID
|
||||
1. 先确定表单所属的真实 `table_id`,并在整个表单管理工作流中复用它;仅在 ID 缺失或归属不明确时调用 `+table-list`。
|
||||
2. 用 `+form-questions-list` 查看现有问题。问题 `id` 是承载该问题的 `field_id`,不是独立于数据表的临时 ID。
|
||||
3. 除非用户明确要求同名的独立问题,否则目标标题已经存在时用 `+form-questions-update` 更新必填状态、标题或描述;不要创建同名问题后再删除旧问题。
|
||||
4. 创建确实不存在的问题,或用户明确要求的同名独立问题,并报告新建的问题 ID。
|
||||
|
||||
`+form-questions-delete` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Check advanced permission status | `+base-get` | Read `data.base.is_advanced`. There is no `+advperm-get` command. |
|
||||
| Enable advanced permissions | `+advperm-enable` | Required before creating or updating roles. Caller must be a Base admin. |
|
||||
| Disable advanced permissions | `+advperm-disable` | High-risk write. Disabling invalidates existing custom roles. |
|
||||
| Locate roles | `+role-list` | Returns role summaries. Use `+role-get` for full config. |
|
||||
@@ -14,6 +15,16 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
|
||||
| Update a role | `+role-update` | Delta merge. Read current config first, then send only intended changes. |
|
||||
| Delete a role | `+role-delete` | Custom roles only. System roles cannot be deleted. |
|
||||
|
||||
## Required order
|
||||
|
||||
At the start of a role workflow, before the first `+role-list`, `+role-get`, `+role-create`, `+role-update`, or `+role-delete` call:
|
||||
|
||||
1. Run `lark-cli base +base-get --base-token <base_token>` and inspect `data.base.is_advanced`.
|
||||
2. If `is_advanced` is `false`, run `+advperm-enable` before the role command. If the user did not authorize enabling advanced permissions, stop and explain the required precondition.
|
||||
3. Run the requested role commands only after `is_advanced` is `true` or `+advperm-enable` succeeds. Reuse that confirmed status for later role calls in the same workflow.
|
||||
|
||||
Do not probe with `+advperm-get`: that command is not supported. Do not use an empty `+role-list` response to infer the advanced permission status; a disabled Base can also return an empty list.
|
||||
|
||||
## Safety boundaries
|
||||
|
||||
- Role operations require advanced permissions to be enabled and the caller to be a Base admin.
|
||||
|
||||
@@ -154,12 +154,34 @@
|
||||
"table_rule_map": {
|
||||
"订单表": {
|
||||
"perm": "edit",
|
||||
"view_rule": { "..." : "..." },
|
||||
"record_rule": { "..." : "..." },
|
||||
"field_rule": { "..." : "..." }
|
||||
"view_rule": {
|
||||
"allow_edit": true,
|
||||
"visibility": { "all_visible": true }
|
||||
},
|
||||
"record_rule": {
|
||||
"record_operations": ["add", "delete"],
|
||||
"other_record_all_read": true
|
||||
},
|
||||
"field_rule": {
|
||||
"field_perm_mode": "all_edit"
|
||||
}
|
||||
},
|
||||
"用户表": {
|
||||
"perm": "read_only"
|
||||
"perm": "read_only",
|
||||
"view_rule": {
|
||||
"allow_edit": false,
|
||||
"visibility": { "all_visible": true }
|
||||
},
|
||||
"record_rule": {
|
||||
"record_operations": [],
|
||||
"other_record_all_read": true
|
||||
},
|
||||
"field_rule": {
|
||||
"field_perm_mode": "all_read"
|
||||
}
|
||||
},
|
||||
"内部表": {
|
||||
"perm": "no_perm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +194,11 @@
|
||||
| `record_rule` | RecordRule | 记录权限配置 |
|
||||
| `field_rule` | FieldRule | 字段权限配置 |
|
||||
|
||||
**注意**: 当 `perm` 为 `no_perm` 时,`view_rule`、`record_rule`、`field_rule` 均无须再设置。
|
||||
**`+role-create` 硬约束**:
|
||||
|
||||
- 当 `perm` 为 `no_perm` 时,不要设置 `view_rule`、`record_rule`、`field_rule`。
|
||||
- 当 `perm` 为其他值时,必须同时提供完整的 `view_rule`、`record_rule`、`field_rule`,缺少任意一项都会导致创建失败。
|
||||
- `+role-update` 是 delta merge,只提交要修改的字段;不要为局部更新补造未变更配置。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ metadata:
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物,使用 `lark-cli drive +preview`。
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`;详细参数、Wiki token 和错误码处理见 [`references/lark-drive-export.md`](references/lark-drive-export.md)。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
@@ -121,7 +121,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
|
||||
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
|
||||
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件的 PDF / HTML / 文本 / 图片等预览产物。 |
|
||||
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物。 |
|
||||
| [`+cover`](references/lark-drive-cover.md) | 查看或下载文件封面图规格。 |
|
||||
| [`+status`](references/lark-drive-status.md) | 比较本地目录与 Drive 文件夹差异;默认按 SHA-256 精确比较,`--quick` 使用修改时间近似比较。 |
|
||||
| [`+pull`](references/lark-drive-pull.md) | 从 Drive 拉取文件到本地目录,支持重复远端路径处理和增量模式。 |
|
||||
|
||||
@@ -25,6 +25,10 @@ https://xxx.feishu.cn/drive/file/boxbc_xxx
|
||||
file_token
|
||||
```
|
||||
|
||||
## 排障
|
||||
|
||||
- 如果返回 `HTTP 403`,可以使用 [lark-drive-preview](lark-drive-preview.md) 下载源文件产物。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
|
||||
|
||||
@@ -2,15 +2,24 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和安全规则。
|
||||
|
||||
列出或下载 Drive 文件可用的预览产物。这个 shortcut 不猜测默认类型:
|
||||
查看或下载 Drive 文件内容,或列出并获取文件可用的预览产物。这个 shortcut 不猜测默认类型:
|
||||
|
||||
- 如果只需要查看或下载文件内容,或不关心 PDF/text/image 等转换预览,优先使用 `--type source_file --output <path>`
|
||||
- 只想看候选项时,用 `--list-only`
|
||||
- 如果需要服务端生成的预览效果,例如 doc/docx 的 PDF 版式预览,先用 `--list-only` 查看候选项,再按候选项选择 `--type pdf` / `text` / `image` 等
|
||||
- 想下载时,必须显式传 `--type` 和 `--output`
|
||||
- 如果 `--list-only` 没有可用预览候选项,或错误提示明确建议使用 `--type source_file`,可以改用 `--type source_file --output <path>` 查看文件内容;资源不存在、token 无效等终态错误需要先修正输入
|
||||
- 如果某个候选项还在生成中,会返回结构化错误并提示先重新 `--list-only`
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
# 查看文件内容
|
||||
lark-cli drive +preview \
|
||||
--file-token "<FILE_TOKEN>" \
|
||||
--type source_file \
|
||||
--output ./artifacts/source
|
||||
|
||||
# 列出可用预览候选项
|
||||
lark-cli drive +preview \
|
||||
--file-token "<FILE_TOKEN>" \
|
||||
@@ -78,6 +87,7 @@ lark-cli drive +preview \
|
||||
|
||||
- 不传 `--list-only` 时,必须显式传 `--type` 和 `--output`
|
||||
- 不会隐式选择“第一个候选项”作为默认下载目标
|
||||
- `--type source_file` 用于查看文件内容,不依赖 `--list-only` 返回的候选项;它适合读取或保存源内容,不等同于 PDF/text/image 等转换预览
|
||||
- 候选项状态来自后端 `preview_status` 枚举,例如 `READY` / `PROCESSING` / `FAILED` / `NO_SUPPORT`
|
||||
- 本地文件名在未显式带扩展名时,会结合响应头自动补扩展名
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-sheets
|
||||
version: 3.0.2
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
version: 3.1.2
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -15,13 +15,7 @@ metadata:
|
||||
|
||||
## 术语约定
|
||||
|
||||
下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念:
|
||||
|
||||
| 标准用语 | 同义 / 口语(均指同一对象) | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 工作表(sheet) | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 |
|
||||
| 电子表格(spreadsheet) | 工作簿、表格 | 顶层容器;由 `--url` 或 `--spreadsheet-token` 定位 |
|
||||
| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image` 的 `--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id |
|
||||
同一对象的交替说法,按此映射解析用户口语:**工作表(sheet)**= 子表 / tab / 标签页(`sheet_id` 是稳定标识);**电子表格(spreadsheet)**= 工作簿 / 表格(顶层容器,由 `--url` 或 `--spreadsheet-token` 定位);**reference_id** = 表内对象的稳定标识,即各对象主键 flag 接受的值(与 `--image-uri` 图片上传句柄不是一回事)。
|
||||
|
||||
每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼):
|
||||
|
||||
@@ -34,32 +28,33 @@ metadata:
|
||||
|
||||
## 飞书表格编辑准则(动手前必守,所有编辑类任务一律生效)
|
||||
|
||||
下列准则横切所有飞书表格任务,**动手前先过一遍**——即使你是被索引直接路由进某个工具参考也一律生效。每条只给一句话纲要,展开与边界见括注的 reference。
|
||||
下列准则横切所有任务,**动手前先过一遍**——被索引直接路由进某个工具参考也一律生效;展开与边界见括注的 reference。
|
||||
|
||||
1. **最小改动**:除任务要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式 1:1 保持;中间结果放原数据右侧或新建空白 Sheet,**禁止删 / 改名 / 隐藏 / 移动已存在 Sheet**;改写类任务精确圈定行列,不该转的原值 1:1 保留。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,写完用 `+csv-get` / `+cells-get` / `+<对象>-list` 回读确认实际生效——**写操作返回 `ok` 只代表请求被接受、不代表结果符合预期**;写公式后查错误码、筛选 / 排序后核对前几行、删除 / 清空后确认已空。禁止只在文本里声称"已完成"。
|
||||
3. **读全再写**:批量填充 / 补齐 / 修正类任务先确认真实数据末行再写,只探前 N 行会漏写表尾(确定末行流程见 `lark-sheets-read-data`)。
|
||||
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找)一律写公式而非静态值;**凡可由表内其它单元格推导的派生值默认就用公式,即使用户没说"联动 / 自动更新"**;写任何飞书公式前先读 `lark-sheets-formula-translation`,而且**只要公式真实写入表格,收尾默认就要继续跑 `lark-sheets-formula-verify` 的 `+formula-verify`,直到 `status='success'`**。
|
||||
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 提取 / 查找)一律写公式而非静态值——**凡可由表内其它单元格推导的派生值默认用公式,即使用户没说"联动"**;写公式前先读 `lark-sheets-formula-translation`,**公式落表后收尾必跑 `+formula-verify` 直到 `status='success'`**。
|
||||
5. **续写 / 扩展继承样式**:续写、补齐、复制区块、新增行列时禁止只读值只写值,必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承(清单见 `lark-sheets-write-cells`,四边框最易漏)。
|
||||
6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`(语义见 `lark-sheets-batch-update`)。
|
||||
6. **多步写入分流**:美化收尾(样式 / 合并 / 行高列宽 / 冻结的任意组合)→ 一次 `+styles-put` 声明式规格交付(见 `lark-sheets-styles-put`);**同一个写操作**打多个区域 → 用该命令自身的复数形态(`--ranges` / map 入参);只有**跨类型、有顺序依赖的操作链**(如插列 → 写表头 → 回填数据)才用 `+batch-update`(high-risk-write:按下方审批协议先获用户同意再带 `--yes`;fail-fast 不回滚,语义见 `lark-sheets-batch-update`)。
|
||||
7. **分组汇总用透视表**:"按 X 统计 Y / 分组汇总 / 各类数量金额"用 `+pivot-{create|update|delete}`,禁止用 SUMIF / 本地脚本拼一张假透视表。
|
||||
8. **拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",逐点 `assert` 全过才交付(多维排序每维一点、多目标每目标一点、范围类核起 / 末 / 边界);只做第一个要点属违规。
|
||||
9. **全量处理前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,先把预期条数硬编码再 `assert actual == expected`,禁止输出"已完成前 N 条,剩余继续"的半成品。
|
||||
10. **缺失值不编造**:补齐 / 扩展 / 按原表格式续填时,查不到或无法确定的值一律留空 + 备注注明("暂未发布 / 未知 / 待核实"),禁止用推算值 / 估算值 / 凭空数据充数;原表若已示范缺失值写法(空值 + 备注),照抄该约定。宁可留空标注,不填不可靠的数。
|
||||
|
||||
> 上述准则的实操展开——读取路径、原生工具优先级、脚本配合、易漏陷阱——见下方「执行要点」节;端到端工作流为:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
|
||||
> 端到端工作流:了解结构(`scripts/lark_inspect_workbook.py` / `+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证;实操展开见下方「执行要点」。
|
||||
|
||||
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
|
||||
|
||||
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。**选定命令后别急着写——先读「动手前读」列指向的 reference 再动手**:命令名对得上不代表用法对,写入 / 清除 / 透视类尤其容易漏掉 reference 里的防错、类型与样式继承规则。
|
||||
把高频意图映射到**真实存在**的 shortcut / flag(agent 常从 Excel / Google Sheets / OpenAPI 误迁移命令名)。**选定命令后先读「动手前读」列指向的 reference 再动手**——命令名对得上不代表用法对。
|
||||
|
||||
| 你要做的事 | ✅ 正确写法 | 动手前读 | ❌ 不存在(会被 cobra 拒) |
|
||||
| --- | --- | --- | --- |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | `lark-sheets-read-data` | `+get-range`、`+range-get`、`+cells-read` |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(`--range` 可省略 = 读整个子表,无需先探行列;限定范围才传) | `lark-sheets-read-data` | `+get-range`、`+range-get`、`+cells-read` |
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `lark-sheets-read-data` | `+get-cell`、`+cell-get`、`--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯文本值(整块 CSV 平铺;列里**没有**需字面保真的数值 / 日期标签 / 编号——点分日期 `12.10`、编号 `001` 会被 csv-put 数值化,不算纯文本) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10`→`12.1`、`001`→`1`,尾零/前导零丢失),改用 `+table-put` 声明 `dtypes:object` |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数等**本质是量值**的数据——不看当下要不要排序 / 求和,量值一律走这里) | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理;要同时美化加 `--styles` 一步带样式(区域底色 / 边框 / 列宽 / 行高 / 合并),不必事后再刷;payload 里不存在的 sheet 名会自动建子表,详见 write-cells) | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力;常见借口见下方 ⚠️) |
|
||||
| 写纯文本值(整块 CSV 平铺;列里没有需字面保真的编号 / 点分日期) | `+csv-put`(定位用 `--start-cell` 左上角锚点格,也接受 `--range` 别名) | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10`→`12.1`、`001`→`1`),改用 `+table-put` 声明 `dtypes:object` |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期等**量值**——不看当下要不要排序求和,量值一律走这里) | `+table-put --sheets '{"sheets":[{"name":…,"columns":[…],"dtypes":{…},"formats":{…},"data":[[…]]}]}'`(不存在的 sheet 名自动建子表;同时美化加 `--styles` 一步带样式,详见 write-cells) | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(落成文本、丢计算能力,见下方 ⚠️) |
|
||||
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢;`--styles` 同样可在建表同一步带全套样式,详见 workbook) | `lark-sheets-workbook` | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
|
||||
| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(定位用 `--range`;批注 / 图片 / 富文本只能用它,公式也可;**公式落表后继续 `+formula-verify` 收尾**) | `lark-sheets-write-cells` | — |
|
||||
| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(单区域 `--range`+`--cells`;**散布多处 / 跨表用 `--writes` 一次批量交付**,每项自带 sheet_name;公式落表后继续 `+formula-verify` 收尾) | `lark-sheets-write-cells` | — |
|
||||
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | `lark-sheets-write-cells` | — |
|
||||
| 插图:**自由摆放、不绑数据**的装饰 / 标识(logo / 水印 / 封面大图 / banner) | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | `lark-sheets-float-image` | — |
|
||||
| 查找 / 替换文本 | `+cells-search`(找,关键字用 `--find`)、`+cells-replace`(替换) | `lark-sheets-search-replace` | `+cells-find`、`+find`、`--query` |
|
||||
@@ -68,37 +63,53 @@ metadata:
|
||||
| 复核某次(AI)编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20) | `lark-sheets-changeset` | — |
|
||||
| 取当前文档 revision(版本号) | `+revision-get` | `lark-sheets-workbook` | — |
|
||||
| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — |
|
||||
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `lark-sheets-workbook` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import`)、要把文件并入某个**已有在线工作簿**(给它加子表)却用它——import 只会新建独立表,加子表应走 `+sheet-copy` / `+sheet-create` |
|
||||
| 参考某个**已有在线表**、把多个本地文件 / 数据各作为一张子表**追加**进去(不另起独立表) | 先 `+workbook-info` 拿模板子表 `sheet_id` → `+sheet-copy` 逐张复制模板子表(公式 / 合并 / 分组底色 / 列宽 / 条件格式全继承)再用 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` 建空子表 + `+table-put --sheets/--styles` 写入 | `lark-sheets-workbook` | 把文件 `+workbook-import` / `+workbook-create` 另起一张**独立新表**(目标是并入已有工作簿时就跑偏了;这两条只产新表、不接受已有表定位) |
|
||||
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `lark-sheets-range-operations` | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `lark-sheets-batch-update` | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `lark-sheets-range-operations` | `--dimension`(无此 flag) |
|
||||
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `lark-sheets-workbook` | `drive +import`(绕路)、本地读 .xlsx 再 `+workbook-create` 重灌(多此一举)、想并入**已有工作簿**却用它(import 只会另起新表,加子表走 `+sheet-copy` / `+sheet-create`) |
|
||||
| 参考某个**已有在线表**、把多份数据各作为一张子表**追加**进去 | 先 `+workbook-info` → `+sheet-copy` 复制模板子表(公式 / 合并 / 底色 / 列宽全继承)再 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` + `+table-put --sheets/--styles` | `lark-sheets-workbook` | `+workbook-import` / `+workbook-create` 另起独立新表(这两条只产新表、不接受已有表定位) |
|
||||
| **已有**表美化收尾(样式 / 边框 / 合并 / 行高列宽 / 冻结的任意组合,单表或多表) | `+styles-put --styles '{"styles":[{"name":…,"cell_styles":[…],"cell_merges":[…],"row_sizes":[…],"col_sizes":[…],"freeze":{…}}]}'`(一份规格一次交付,词汇同 `+table-put --styles`) | `lark-sheets-styles-put` | 拼 `+batch-update` 的 `--operations` 子操作数组做美化、逐区域多次 `+cells-set-style` |
|
||||
| 清除内容 / 格式 | `+cells-clear`(high-risk-write 需用户确认后带 `--yes`;范围维度用 `--scope`,取值 content / formats / all) | `lark-sheets-range-operations` | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear`(high-risk-write 需用户确认后带 `--yes`;`--scope`) | `lark-sheets-batch-update` | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令;连同样式一起调时并入 `+styles-put` 的 `row_sizes` / `col_sizes`) | `lark-sheets-range-operations` | `--dimension`(无此 flag) |
|
||||
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | `lark-sheets-pivot-table` | 用 SUMIF / 本地脚本拼一张假透视表 |
|
||||
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create` | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
|
||||
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create`(先 `+chart-create --print-example <column\|bar\|line\|pie\|combo…>` 本地拿最小可用 `--properties` 模板,改 refs / index 即可用) | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
|
||||
| 条件高亮 / 数据条 / 色阶 / 重复值标记 | `+cond-format-create` | `lark-sheets-conditional-format` | `+highlight`、`+conditional-format`、逐格 `+cells-set-style` 硬凑 |
|
||||
| 筛选 / 只看符合条件的行 | `+filter-create` | `lark-sheets-filter` | pandas filter 后覆盖写回(会毁原数据;要保存多份筛选状态用 `+filter-view-create`) |
|
||||
|
||||
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**:本次操作只要**涉及样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 汇总行 / 配色 / 列宽行高),动手前先读 `lark-sheets-visual-standards`;只要**要写飞书公式**,动手前先读 `lark-sheets-formula-translation`(飞书函数与 Excel 有差异,凭直觉迁移易错),**写完后再读 `lark-sheets-formula-verify` 并执行 `+formula-verify` 收尾**。哪怕主任务是"建表 / 展开数据 / 录入",只要动作里含美化或写公式就适用——别因"这不算专门的美化 / 公式任务"而跳过。
|
||||
> ⚠️ **两种图片别选错**:图若**绑定某条记录、要随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`;只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。
|
||||
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 比率 / 计数 / 日期等**本质是量值**的数据 → 一律数值写入,常规二维表用 `+table-put`(`dtypes` 声明类型 + `formats` 设展示格式),版式装不下(多级 / 合并表头的宽表 leaderboard 等)改用 `+cells-set` 传数字(百分比传小数 `0.4`)+ `number_format`,照样显示 `40%` 且数值无损。只有编号 / 身份证 / 单据号这类**本质是标识符**、要字面保真的才用 `+csv-put` 平铺。**几个常见借口都不成立**——"只是 leaderboard / 报表展示不用算""版式复杂""样式以后再刷、先铺文本"都不是把百分比写成 `"40%"` 字符串灌 `+csv-put` 的理由(展示不改变它是数值;类型不能后补,落成文本就回不来)。判据与操作展开见 `lark-sheets-write-cells`「数字还是文本」。
|
||||
> ⚠️ **要新建子表 / 整表美化 → 别默认「`+csv-put` 写值再事后刷样式」**:`+table-put` / `+workbook-create` 的 `--styles` 能在写数据的**同一步**带全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并),且 `+table-put` 的 payload 里若 sheet 名不在工作簿中会自动新建子表——**纯文本表要新建子表 + 美化时同样走这里**(`--styles` 与列是否 typed 无关),比「`+csv-put` 写值 + 多次 `+cells-batch-set-style` / `+*-resize` 刷样式」少好几次调用(冻结行列等 sheet 级属性仍需 `+dim-freeze` 单独一步)。
|
||||
> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
|
||||
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
|
||||
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**:动作里**含样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 配色 / 列宽行高)→ 先读 `lark-sheets-visual-standards`;**要写飞书公式** → 先读 `lark-sheets-formula-translation`,写完跑 `+formula-verify` 收尾(见 `lark-sheets-formula-verify`)。主任务是建表 / 录入也一样适用。
|
||||
> ⚠️ **两种图片别选错**:图**绑定某条记录、随行走**(凭证 / 证件照 / 每行配图)→ `+cells-set-image`;自由摆放的装饰(logo / 水印 / 封面)→ `+float-image-create`。别因「浮动图更熟」默认选浮动图。
|
||||
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 日期 / 计数等**量值**一律数值写入——常规二维表用 `+table-put`(`dtypes` + `formats`),宽表 / 合并表头版式用 `+cells-set` 传数字(百分比传小数 `0.4`)+ `number_format`。只有编号 / 身份证等**标识符**才 `+csv-put` 平铺。"只是展示不用算 / 样式以后再刷"不构成把量值写成字符串的理由——类型不能后补。判据见 `lark-sheets-write-cells`「数字还是文本」。
|
||||
> ⚠️ **要新建子表 / 整表美化 → 别「`+csv-put` 写值再事后刷样式」**:`+table-put` / `+workbook-create` 的 `--styles` 在写数据**同一步**带全套样式(底色 / 边框 / 列宽行高 / 合并 / 冻结),payload 里不存在的 sheet 名自动建子表,纯文本表同样适用;比事后多次刷样式少好几次调用。存量表事后美化则一次 `+styles-put` 交付(同一份 `--styles` 词汇)。
|
||||
> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 用 `--start-cell`(也接受 `--range` 别名,区间取左上角)。
|
||||
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`(无 `--with-styles` 这类 flag);**看合并单元格**用 `+sheet-info` 的 `merged_cells`。
|
||||
|
||||
💡 **高频写命令签名(照抄改参即可;各命令 `--help` 的 Tips 段有同款示例)**:
|
||||
|
||||
```bash
|
||||
lark-cli sheets +cells-set --url <U> --sheet-name S1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]' # --cells 恒为二维数组 [[…]],单格也是 [[{…}]]
|
||||
lark-cli sheets +cells-set-style --url <U> --sheet-name S1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center
|
||||
lark-cli sheets +styles-put --url <U> --styles - <<'JSON'
|
||||
{"styles":[{"name":"S1","cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#F0F0F0"}],"col_sizes":[{"range":"A:D","type":"pixel","size":120}],"freeze":{"rows":1}}]}
|
||||
JSON
|
||||
lark-cli sheets +batch-update --url <U> --dry-run --operations - <<'JSON' # high-risk:先 --dry-run 给用户看,同意后原样重发并追加 --yes
|
||||
[{"shortcut":"+cells-set","input":{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}}]
|
||||
JSON
|
||||
lark-cli sheets +dim-freeze --url <U> --sheet-name S1 --rows 1 --cols 2 # 一次给全;冻结是整份状态覆盖,没写的轴即为不冻结
|
||||
lark-cli sheets +dim-insert --url <U> --sheet-name S1 --position 3 --count 2 --inherit-style before # 行/列由 --position 决定:数字=行、字母=列,无 --dimension
|
||||
lark-cli sheets +cols-resize --url <U> --sheet-name S1 --range A:C --width 120 # 像素;分列不同宽用 --widths '{"A":80,"C:E":120}'
|
||||
lark-cli sheets +sheet-copy --url <U> --sheet-name 源表名 --title 副本名 # --sheet-name=源表、--title=新表名
|
||||
```
|
||||
|
||||
## 执行要点(读取 / 原生工具 / 陷阱)
|
||||
|
||||
准则的实操展开。端到端工作流:了解结构 → 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
|
||||
|
||||
### 读取:按需求选路径(细则见 `lark-sheets-read-data`)
|
||||
|
||||
| 用户需求 | 读取路径 |
|
||||
|---|---|
|
||||
| "完善 / 补齐 / 填空 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行,不以用户选区为准) |
|
||||
| "查一下 / 看看 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
|
||||
| "完善 / 补齐 / 修正所有 XX"、分析 / 清洗 / 大数据 | 先 `scripts/lark_profile_table.py` 确认目标区域与字段画像,再原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行) |
|
||||
| "查一下 / 统计 / 汇总"等只读 | 小表 `+csv-get` 读到上下文;大表先 `+workbook-info` + 小窗口 `+csv-get` 定边界,再对未截断窗口跑 `scripts/lark_detect_subtables.py` / `scripts/lark_profile_table.py` |
|
||||
| 需要公式 / 样式 / 批注 | `+cells-get` |
|
||||
| 续写 / 扩展已有内容 | `+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见准则 5) |
|
||||
|
||||
> "补齐 / 填空"类用只读路径探 10 行就写会漏写表尾——写入前先按 `lark-sheets-read-data` 确认真实数据末行(准则 3)。
|
||||
> "补齐 / 填空"类只探前 10 行就写会漏写表尾——先按 `lark-sheets-read-data` 确认真实数据末行(准则 3)。
|
||||
|
||||
### 计算:原生工具优先,代码兜底(强化准则 7)
|
||||
|
||||
@@ -116,22 +127,23 @@ metadata:
|
||||
### 用脚本配合 CLI 时
|
||||
|
||||
- **只读 stdout**:CLI 数据走 stdout、诊断走 stderr;解析 JSON 别 `2>&1`(警告混入会解析失败),用管道或单独重定向 stdout。
|
||||
- **读表理解优先用 `scripts/lark_*.py`(若可用)**:`lark_inspect_workbook.py` / `lark_detect_subtables.py` / `lark_profile_table.py` 是只读脚本,用来把在线表格整理成结构摘要。**可选增强,不是必经步骤**——`scripts/` 只随仓库版 skill 分发,二进制内嵌版没有这些文件;本地不存在时直接用 CLI 等价路径(对照表见 `lark-sheets-read-data`:`+workbook-info` / `+sheet-info` / 小窗口 `+csv-get`)。它们不替代写入类 shortcut;确认目标区域后,写入仍按对应 reference 执行。
|
||||
- **喂 CLI 的 CSV / JSON 用 UTF-8 无 BOM**;临时文件放系统临时目录、勿落项目目录。
|
||||
- **命令失败先读 stderr 再调整**,别原样重发。
|
||||
- **回写纯单元格值**:剥离 `值(V-Align: bottom)` 这类"值(样式)"串与残留引号再写;排序优先 `+range-sort` 原生工具,别"读出本地排完再整列写回"。
|
||||
|
||||
### 易漏陷阱
|
||||
|
||||
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框,新行回落默认高度截断长文本;插行填长文本前读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。
|
||||
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首末各 5 行查 `#VALUE!` / `#REF!` / `#DIV/0!`,然后继续跑 `+formula-verify` 直到 `status='success'`;同一方案试错上限 3 次。
|
||||
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框;插行填长文本前读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。
|
||||
- **公式容错**:日期 / 查找 / 转换公式用 `IFERROR` 包裹;写完查首末各 5 行错误码,再跑 `+formula-verify` 到 `status='success'`;同一方案试错上限 3 次。
|
||||
- **循环引用**:聚合公式引用范围不能含目标 cell 自身或其传递依赖。
|
||||
- **隐藏行列**:`+csv-get` 默认含隐藏行列;设 `--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
|
||||
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。
|
||||
- **NLP 任务分批**:语义理解 / 翻译 / 改写 / 分类等用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量大必须分批(通常 30 行 / 批),每批处理完即时写回,单批生成通常 ≤ 300 行,多批用 `+batch-update`。
|
||||
- **隐藏行列**:`+csv-get` 默认含隐藏行列;`--skip-hidden=true` 只看可见,真实行号会跳空——禁止按返回数组下标推导行号,用 `annotated_csv` 的 `[row=N]` 或 `row_indices`。
|
||||
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,先 `+workbook-info` 掌握全局。
|
||||
- **NLP 任务分批**:语义理解 / 翻译 / 打标用 NLP 处理(代码只做分批 / 行号映射 / 写回);大数据量分批(约 30 行 / 批)即时写回,多批用 `+batch-update`。
|
||||
|
||||
## References
|
||||
|
||||
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
|
||||
reference 分两组:先读**通用方法与规范**(横切所有任务的样式 / 公式规则),再按操作对象进入**工具参考**查具体 shortcut。编辑类任务务必先过通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
|
||||
|
||||
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut)
|
||||
|
||||
@@ -151,6 +163,7 @@ metadata:
|
||||
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。 |
|
||||
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 批量铺到表格上(值或公式,不带样式/批注),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。只要这次写入真实落了公式,收尾默认继续执行 `lark-sheets-formula-verify`。 |
|
||||
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。 |
|
||||
| [Lark Sheet Styles Put](references/lark-sheets-styles-put.md) | 把一份声明式视觉规格(样式/边框/合并/行高列宽/冻结)一次性应用到已有飞书表格的多个子表,整份规格一次提交。当任务是对存量表做美化收尾、批量刷样式、统一版式时使用。样式取值标准见 lark-sheets-visual-standards;建新表带样式走 lark-sheets-workbook(+workbook-create --styles)、写数据同步带样式走 lark-sheets-write-cells(+table-put --styles),三者共用同一份 --styles 词汇。仅针对飞书表格。 |
|
||||
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。 |
|
||||
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。 |
|
||||
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。 |
|
||||
@@ -164,42 +177,22 @@ metadata:
|
||||
|
||||
## 公共 flag 速查
|
||||
|
||||
各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如:
|
||||
|
||||
- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」),加 `--dry-run`
|
||||
- `_公共:URL/token(无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token,常见于 `+batch-update` 等不强制 sheet 定位的 shortcut
|
||||
|
||||
徽章里只列名字。type / 必填 / 描述都在本段统一声明:
|
||||
各 reference 的 shortcut 标题下用一行徽章标注支持的公共 / 系统 flag(如 `_公共四件套 · 系统:--dry-run_`;`_公共:URL/token(无 sheet 定位)…_` 表示只接 URL/token)。type / 必填 / 描述在本段统一声明:
|
||||
|
||||
### 公共 flag(定位资源)
|
||||
|
||||
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"):
|
||||
|
||||
1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
|
||||
- **`--url` 解析 `/sheets/`、`/spreadsheets/` 与 `/wiki/` 三种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
|
||||
- **`/wiki/` 知识库链接可直接传 `--url`**:会自动定位到链接背后的电子表格;若该链接背后不是电子表格(而是文档 / 多维表格等),则报错。
|
||||
- **例外**:`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--values` / `--styles` / `--sheets`,`+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`。
|
||||
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。
|
||||
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
|
||||
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。
|
||||
- ⚠️ **A1 reference 含 `!`**(`--source` / `--range` / `--ranges`)**:整段用单引号包裹**,如 `--range 'Sheet1!A1:B2'`——单引号能挡住 bash 的 history expansion(`!` 被拦成 `event not found`;双引号挡不住;别改用 `set +H`,原因见下方「复合 JSON / 大入参」)。sheet 名含特殊字符(`-` / 空格 / 非 ASCII)需在内部按 A1 标准再包一层单引号时,用 `'\''` 转义保持外层单引号,如 `--source ''\''Sales-2025'\''!A1:D100'`。
|
||||
- **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--url` | string | 二选一必填(与 `--spreadsheet-token`) | spreadsheet 或 wiki URL |
|
||||
| `--spreadsheet-token` | string | 二选一必填(与 `--url`) | spreadsheet token |
|
||||
| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut) | 工作表 reference_id |
|
||||
| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut) | 工作表名称 |
|
||||
|
||||
**统一调用范式**(公共四件套 shortcut 的所有示例都遵循此形状,两组定位缺一不可):
|
||||
1. **spreadsheet 定位(必填)**:`--url`(解析 `/sheets/`、`/spreadsheets/`、`/wiki/` 三种链接;wiki 链接自动定位背后的电子表格)与 `--spreadsheet-token`(裸 token)二选一。**例外**:`+workbook-create` / `+workbook-import` 产出**还不存在**的表,不接受任何定位 flag。
|
||||
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一。
|
||||
- ⚠️ **不确定 sheet 名时禁止猜 `Sheet1`**:除非对话或上下文已出现具体值,第一步先 `+workbook-info` 拿 `sheets[].sheet_id/title` 再选——中文表的子表常叫"数据"/"工作表 1"/业务名,猜名大概率撞 `sheet not found`。
|
||||
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:仍必须传 `--sheet-id` / `--sheet-name`。
|
||||
- ⚠️ **A1 引用含 `!` 时整段用单引号包裹**(`--range 'Sheet1!A1:B2'`,挡 bash history expansion;别用 `set +H`,sh/dash 下非法)。sheet 名含 `-`/空格需内层再包单引号时用 `'\''` 转义:`--source ''\''Sales-2025'\''!A1:D100'`。
|
||||
- **例外**:徽章标 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(`+workbook-info` / `+workbook-export` / `+batch-update` / `+styles-put` / `+dropdown-update|delete` / `+cells-batch-clear` / `+sheet-create`)不接受 sheet 定位。`+pivot-create` 用 `--target-sheet-id/name`(XOR,可都不传)。
|
||||
|
||||
```bash
|
||||
lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
|
||||
# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给)
|
||||
# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填)
|
||||
# 例:lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
|
||||
# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。
|
||||
# 统一调用范式:两组定位缺一不可(占位符别原样填;表名先 +workbook-info 查)
|
||||
lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
|
||||
```
|
||||
|
||||
### 系统 flag
|
||||
@@ -208,27 +201,35 @@ lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
|
||||
| --- | --- | --- | --- |
|
||||
| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 |
|
||||
| `--yes` | bool | 是(仅 `high-risk-write`) | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 |
|
||||
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name <name>` 搭配指定要查哪个 flag;省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 |
|
||||
| `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 |
|
||||
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起调用、不需要其它 required flag。搭配 `--flag-name` 指定查哪个 flag;省略时列出该 shortcut 可查询的 flag。仅对含复合 JSON flag 的 shortcut 有效。 |
|
||||
| `--flag-name` | string | 否 | 配合 `--print-schema`:flag 名不带 `--` 前缀(`cells` / `properties`)。**支持点分路径切片**:`--flag-name properties.snapshot.plotArea.axes` 只打印该子树,大 schema(chart 的 properties 约 1700 行)按需取,别整篇翻页。 |
|
||||
|
||||
**Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets <shortcut> --print-schema --flag-name <name>` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。
|
||||
> ⚠️ **high-risk-write 命令清单(exit 10 强确认门禁)**:`+batch-update`、`+cells-clear`、`+cells-batch-clear`、`+sheet-delete`、`+dim-delete`、`+dropdown-delete`,以及各对象删除 `+chart-delete` / `+pivot-delete` / `+cond-format-delete` / `+filter-delete` / `+filter-view-delete` / `+sparkline-delete` / `+float-image-delete`。
|
||||
>
|
||||
> **审批协议**:先 `--dry-run` 预览、向用户展示将执行的操作与影响范围,**获得用户明确同意后**再在原命令追加 `--yes` 执行。未经用户同意不得带 `--yes`,也不得在 exit 10 后静默补 `--yes` 重试——那等于禁用门禁。完整协议见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
|
||||
|
||||
**Agent 使用提示**:写复合 JSON flag 前对结构不确定时,先 `--print-schema --flag-name <name>`(深层字段用点分路径切片)再构造 payload;图表直接 `+chart-create --print-example <type>` 拿最小可用模板改参。reference 的 `## Schemas` 段只给一层结构。
|
||||
|
||||
### flag 内容类型与输出约定(术语速记)
|
||||
|
||||
- flag 表里 JSON 类入参标三类:**复合 JSON** = 深层嵌套对象(用 `--print-schema` 取完整结构);**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema);**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)。
|
||||
- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get` 的 `annotated_csv`);写操作不会自动回读,如需校验请自行调用对应的 `+*-list` / `+*-get` / `+cells-get`。
|
||||
- JSON 类入参分三类:**复合 JSON** = 深层嵌套对象(`--print-schema` 可查);**简单 JSON** = 一二维标量数组;**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对复合 JSON flag 有效。
|
||||
- **envelope**:所有 shortcut 返回统一外层 `{ok, identity, data, ...}`;写操作不会自动回读,校验自行调用 `+*-list` / `+*-get` / `+cells-get`。
|
||||
|
||||
## 复合 JSON / 大入参:优先 stdin
|
||||
|
||||
flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。
|
||||
|
||||
推荐写法:payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去:
|
||||
大 payload(`--operations` / `--cells` / `--sheets` / `--styles` / `--properties`…)、或含换行 / 引号 / `!` 等特殊字符时,优先 heredoc stdin(`-`)传入,避免命令行超长与 shell 转义问题:
|
||||
|
||||
```bash
|
||||
# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录)
|
||||
lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE"
|
||||
lark-cli sheets +batch-update --url "..." --dry-run --operations - <<'JSON' # high-risk:先 --dry-run,用户同意后再追加 --yes 重发
|
||||
[{"shortcut":"+cells-set","input":{...}}]
|
||||
JSON
|
||||
```
|
||||
|
||||
**参数含特殊字符(`!` / 引号 / 空格 / 非 ASCII)时,用单引号包裹该参数即可,不要起手 `set +H` 之类的 shell 开关来防转义。** `set +H`(关 bash history expansion)在 `sh` / `dash` 下是非法选项(`set: Illegal option -H`)、会让整条命令直接失败;而单引号挡得住 `!` 的 history expansion(否则报 `event not found`),对 bash 与 `sh` / `dash` 一致安全。参数本身含单引号、或 payload 较大时,按上文走 stdin。
|
||||
- **stdin 每次调用只能给一个 flag**:`+table-put` 同时传 `--sheets` 与 `--styles` 两个大 JSON 时,一个走 `-`、另一个走 `@./styles.json`(`@file` 只接受 cwd 下相对路径,**绝对路径会被拒**;正解是 stdin,别 cd、别把临时文件写进用户项目目录)。
|
||||
- **参数含特殊字符时用单引号包裹即可,不要 `set +H`**(sh/dash 下非法直接报错);参数本身含单引号或 payload 大时走 stdin。
|
||||
- **非 POSIX shell(PowerShell / cmd.exe)适配**:本 skill 全部 `bash` 代码块(heredoc `<<'JSON'`、单引号转义 `'\''`)只适用于 bash / zsh,动手前先判断当前 shell,非 POSIX 环境按下表改写,**不要试错式改引号**——`@file`(cwd 相对路径)是全平台无引号问题的兜底形态:
|
||||
|
||||
**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`--<flag> - < 文件`)。
|
||||
| 形态 | bash / zsh | PowerShell | cmd.exe |
|
||||
| --- | --- | --- | --- |
|
||||
| 大 / 多行 JSON | `--flag - <<'JSON' … JSON` | 先写 UTF-8 无 BOM 文件再 `--flag '@./x.json'`,或 `Get-Content -Raw ./x.json \| lark-cli … --flag -` | 先写文件再 `--flag @./x.json`(cmd 无 heredoc / 管道读文件不可靠) |
|
||||
| 单行 inline JSON | `--flag '{"a":1}'` | `--flag '{"a":1}'`(PS 单引号同为字面量) | 不要 inline——cmd 会吃掉内层双引号,一律走 `@file` |
|
||||
|
||||
@@ -8,26 +8,23 @@
|
||||
2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照。
|
||||
3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。
|
||||
|
||||
若本次 `+batch-update` 的任一子操作写入了公式、复制了公式模板、或导入了含公式的数据块,**回读校验之后还必须继续执行 `+formula-verify`**。`+batch-update` 的原子提交只保证“写入动作都执行了”,不保证整批公式运行结果 zero-error。
|
||||
若本次 `+batch-update` 的任一子操作写入了公式、复制了公式模板、或导入了含公式的数据块,**回读校验之后还必须继续执行 `+formula-verify`**。`+batch-update` 只保证"写入动作按序执行了",不保证整批公式运行结果 zero-error。
|
||||
|
||||
## 使用场景
|
||||
|
||||
写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 `+batch-update`。
|
||||
写入。把**跨类型、有顺序依赖**的多个写入操作合并为一次请求按序执行(如插列 → 写表头 → 回填数据)。注意:不支持嵌套 `+batch-update`。
|
||||
|
||||
**不可放进 `--operations` 的写 shortcut**(`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+dropdown-update` / `+dropdown-delete` / `+cells-batch-set-style` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。
|
||||
**先分流再动手(按操作组合选入口)**:美化收尾(样式 / 合并 / 行高列宽 / 冻结的任意组合)→ 一次 `+styles-put`(声明式规格,见 `lark-sheets-styles-put`),不要拼 `--operations` 子操作数组;**同一个写操作**打多个区域 → 用该命令自身的复数形态(`+cells-set --writes` / `+cells-batch-clear` / `+dim-delete --ranges` / resize 的 map 形态等);只有跨类型、有顺序依赖的操作链才用本命令。
|
||||
|
||||
**⚠️ 何时必须使用 `+batch-update`(硬性要求)**:
|
||||
- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容)
|
||||
- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`)
|
||||
- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合)
|
||||
**不可放进 `--operations` 的写 shortcut**(`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+styles-put` / `+dropdown-update` / `+dropdown-delete` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。
|
||||
|
||||
**行高列宽批量不走这里**:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态(如 `--widths '{"A":100,"C:E":120}'`,见 `lark-sheets-range-operations`),一次调用原子完成;map 形态不可作为 `--operations` 子操作嵌入(子操作里仍可用单区间形态 `range` + `height`/`width`)。
|
||||
**行高列宽批量不走这里**:多行 / 多列不同尺寸用 `+styles-put` 的 `row_sizes` / `col_sizes`(可与样式同批),或 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态(见 `lark-sheets-range-operations`);map 形态不可作为 `--operations` 子操作嵌入(子操作里仍可用单区间形态 `range` + `height`/`width`)。
|
||||
|
||||
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
|
||||
**执行语义(fail-fast,不回滚)**:默认首个失败的子操作即中断剩余操作,但**已执行成功的子操作不回滚**——服务端报 "N succeeded, M failed" 时前 N 个已实际生效。修复失败项后**只重发失败起的剩余子集**,整批重发会把已成功的操作(如插行)重复应用。传 `--continue-on-error` 则遇失败仍继续执行剩余操作。正因如此,含结构变更(插删行列 / 移动)的批次失败后要先回读确认现状再续发。
|
||||
|
||||
**公式相关批处理的默认闭环**:
|
||||
- 写前:先读 `lark-sheets-formula-translation`,把公式改写成飞书可执行语义。
|
||||
- 写时:用 `+batch-update` 一次性完成插行/写公式/复制模板等原子动作。
|
||||
- 写时:用 `+batch-update` 一次性完成插行/写公式/复制模板等成套动作。
|
||||
- 写后:抽样回读之外,继续跑 `lark-sheets-formula-verify`,直到 `+formula-verify` 返回 `status='success'`。
|
||||
|
||||
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本文不重复。`+dropdown-delete` 不涉及这些 flag。
|
||||
@@ -37,7 +34,6 @@
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+batch-update` | high-risk-write | 批量 |
|
||||
| `+cells-batch-set-style` | write | 批量 |
|
||||
| `+dropdown-update` | write | 对象 |
|
||||
| `+dropdown-delete` | high-risk-write | 对象 |
|
||||
| `+cells-batch-clear` | high-risk-write | 批量 |
|
||||
@@ -50,29 +46,9 @@ _公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON(如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name <flag>;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认严格事务(首个失败即整批中断),传 --continue-on-error 切换为软批量(遇失败仍继续);不支持嵌套;按数组顺序串行执行 |
|
||||
| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON(如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name <flag>;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认 fail-fast:首个失败即中断剩余操作,**已执行的子操作不回滚**(服务端报 "N succeeded, M failed" 时 N 个已生效,修复后只重发失败起的剩余子集,不要整批重发);传 --continue-on-error 遇失败仍继续;不支持嵌套;按数组顺序串行执行 |
|
||||
| `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) |
|
||||
|
||||
### `+cells-batch-set-style`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个),每项必须带 sheet 前缀(如 `["Sheet1!A1:B2","Sheet2!D1:D10"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style |
|
||||
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) |
|
||||
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) |
|
||||
| `--font-family` | string | optional | 字体名称(如 `Arial`、`微软雅黑`) |
|
||||
| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) |
|
||||
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) |
|
||||
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) |
|
||||
| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through`) |
|
||||
| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right`) |
|
||||
| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) |
|
||||
| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) |
|
||||
| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) |
|
||||
| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON(结构同 +cells-set-style) |
|
||||
|
||||
### `+dropdown-update`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
@@ -115,16 +91,6 @@ _要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;
|
||||
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +sheet-show-gridline / +sheet-hide-gridline / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
|
||||
- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 …
|
||||
|
||||
### `+cells-batch-set-style` `--border-styles`
|
||||
|
||||
_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_
|
||||
|
||||
**顶层字段**:
|
||||
- `top` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `bottom` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `left` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `right` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
|
||||
### `+dropdown-update` `--options`
|
||||
|
||||
_列表选项_
|
||||
@@ -156,7 +122,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
|
||||
> - **每个子操作的子表定位 `sheet_id`(或 `sheet_name`)写进它自己的 `input`**(见上方 ops.json 每个 item)。
|
||||
> - `input` 的键是该 shortcut 的 flag **展平**成 JSON(`"range":"A11:B12"`、`"position":11`),不要把整组 `--operations` 再套一层嵌套 JSON。
|
||||
|
||||
> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。
|
||||
> **常见组合:插列 + 写表头 + 整列回填**——一次批量提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。
|
||||
>
|
||||
> ```jsonc
|
||||
> // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行
|
||||
@@ -169,20 +135,9 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
|
||||
> ]
|
||||
> ```
|
||||
|
||||
### `+cells-batch-set-style`
|
||||
|
||||
多 range 应用同一组 style(服务端走 `+batch-update` 原子事务):
|
||||
|
||||
```bash
|
||||
# 表头行 + 汇总行同时刷成蓝底白字
|
||||
lark-cli sheets +cells-batch-set-style --url "..." \
|
||||
--ranges '["sheet1!A1:F1","sheet1!A30:F30"]' \
|
||||
--background-color "#1E5BC6" --font-color "#FFFFFF" --font-weight bold
|
||||
```
|
||||
|
||||
### `+cells-batch-clear`
|
||||
|
||||
多 range 一次性清除(服务端走 `+batch-update` 原子事务);`--scope` 同 `+cells-clear`(`content` / `formats` / `all`,默认 `content`),`high-risk-write` 强制 `--yes`:
|
||||
多 range 一次性清除(服务端走 `+batch-update` 批量提交,fail-fast、不回滚);`--scope` 同 `+cells-clear`(`content` / `formats` / `all`,默认 `content`),`high-risk-write` 强制 `--yes`:
|
||||
|
||||
```bash
|
||||
# dry-run 先看清除范围
|
||||
@@ -195,6 +150,6 @@ lark-cli sheets +cells-batch-clear --url "..." \
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。`+cells-batch-clear` 的 `--ranges` 同样必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。
|
||||
- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。
|
||||
- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 `+batch-update` 的语义)。
|
||||
- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验,input 键必须在该 shortcut 的 flag 词汇表内(未知键报错并提示最近似键与完整键契约);**校验错误聚合上报**——所有子操作的首错一次性返回,全部修完再重发一次即可;**禁止嵌套 `+batch-update`**。`+cells-batch-clear` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。
|
||||
- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板,不发起调用。
|
||||
- `Execute`:按声明顺序串行执行;默认 fail-fast——任一子操作失败即中断剩余操作,**已成功的子操作不回滚**,报错会注明已生效数量与「仅重发失败起的剩余子集」的续发方式。
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
|
||||
|
||||
**`--properties` 结构锚点(构造前必读)**:`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。完整结构以 `lark-cli sheets +chart-create --print-schema --flag-name properties` 为准。
|
||||
**`--properties` 结构锚点(构造前必读)**:`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。**构造起点优先用 `lark-cli sheets +chart-create --print-example <column|bar|line|area|pie|scatter|radar|combo>` 拿最小可用模板改参**(本地即时返回);查深层字段用点分路径切片 `--print-schema --flag-name properties.snapshot.plotArea.axes`,别整篇 dump 翻页。完整结构以 `--print-schema --flag-name properties` 为准。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column`
|
||||
@@ -125,6 +125,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`);必须至少含 `snapshot.data.dim1.serie.index` 或 `dim2.series[].index` 之一,否则 server 拒。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` |
|
||||
| `--print-example` | string | optional | 打印指定图表类型的最小可用 `--properties` 模板后直接退出(`area` / `bar` / `column` / `combo` / `line` / `pie` / `radar` / `scatter`)。纯本地执行,不需要 locator flag、不发网络请求;传入未知类型时列出全部可用类型 |
|
||||
|
||||
### `+chart-update`
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \
|
||||
lark-cli sheets +cond-format-delete --url "..." --sheet-id "$SID" --rule-id "$RULE_ID" --yes
|
||||
```
|
||||
|
||||
> 一次只删一个 `--rule-id`。要删**多个**条件格式时,先 `+cond-format-list` 拿到各 `rule-id`,再用 `+batch-update` 把多个 `+cond-format-delete` 合并为单次原子提交,不要逐个调用。
|
||||
> 一次只删一个 `--rule-id`。要删**多个**条件格式时,先 `+cond-format-list` 拿到各 `rule-id`,再用 `+batch-update` 把多个 `+cond-format-delete` 合并为单次批量提交(fail-fast、不回滚),不要逐个调用。
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
5. **新增合并时数据保护**:合并前确认目标区域只有左上角有数据,其余单元格为空,否则合并会导致非左上角的数据丢失。
|
||||
6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-unmerge` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge,也不要用 `+batch-update` 拆成多次 unmerge。
|
||||
|
||||
**⚠️ 批量操作必须用 `+batch-update`**:对**多个**不同区域执行 `+cells-merge` 时,禁止逐个调用,合并为单次原子 `+batch-update`(语义与 `--operations` 入参格式见 `lark-sheets-batch-update`)。行高列宽**不需要** `+batch-update`:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态,一次调用原子完成。
|
||||
**⚠️ 多区域合并不要逐个调用**:对**多个**不同区域执行 `+cells-merge` 时,写成一份 `+styles-put --styles` 的 `cell_merges` 一次交付(合并与样式 / 行高列宽 / 冻结同属一份声明式规格,见 `lark-sheets-styles-put`);只有当合并夹在**跨类型、有顺序依赖**的操作链里(如插列 → 合并 → 写表头)才用 `+batch-update`(fail-fast、不回滚,入参格式见 `lark-sheets-batch-update`)。行高列宽同理**不需要** `+batch-update`:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态,一次调用完成。
|
||||
|
||||
**唯一例外**:`+cells-unmerge` 原生支持传一个大 range 一次性取消其中所有合并区域,应直接单次调用,**不要**拆进 `+batch-update`。
|
||||
|
||||
@@ -129,7 +129,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--height` | int | xor | 统一行高(像素,例:30 / 40 / 60;不是磅/points),配 `--range` 使用。传了 `--height` 就是像素模式,可以省略 `--type`;显式 `--type pixel` 也行(等价)。多行不同高用 `--heights` |
|
||||
| `--heights` | string + File + Stdin(复合 JSON) | xor | 差异化行高 map,一次原子调用给多行设置不同高度:键为单行(`"1"`)或行闭区间(`"2:20"`),值为像素高(如 30 / 50)、`"auto"`(自适应内容)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是磅/points。与 `--range` / `--height` / `--type` 互斥 |
|
||||
| `--heights` | string + File + Stdin(复合 JSON) | xor | 差异化行高 map,一次调用给多行设置不同高度:键为单行(`"1"`)或行闭区间(`"2:20"`),值为像素高(如 30 / 50)、`"auto"`(自适应内容)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是磅/points。与 `--range` / `--height` / `--type` 互斥 |
|
||||
| `--type` | string | xor | 尺寸方式 enum:`pixel`(需配 `--height`)/ `standard`(重置为默认行高)/ `auto`(自动适应内容)。常规写法直接给 `--height` 即可省略本 flag;`--type standard` / `--type auto` 不能与 `--height` 同时给(可选值:`pixel` / `standard` / `auto`) |
|
||||
| `--range` | string | xor | 要调整行高的行闭区间;1-based 行号如 `2:10` 或单行 `5`。统一尺寸形态必填(配 `--height` 或 `--type`);map 形态(`--heights`)不传 |
|
||||
|
||||
@@ -140,7 +140,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--width` | int | xor | 统一列宽(像素,例:80 / 120 / 200;不是 Excel 字符单位),配 `--range` 使用。传了 `--width` 就是像素模式,可以省略 `--type`;显式 `--type pixel` 也行(等价)。多列不同宽用 `--widths` |
|
||||
| `--widths` | string + File + Stdin(复合 JSON) | xor | 差异化列宽 map,一次原子调用给多列设置不同宽度:键为单列(`"A"`)或列闭区间(`"C:E"`),值为像素宽(如 80 / 120 / 200)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是 Excel 字符单位(像素 ≈ 字符数×8+16)。与 `--range` / `--width` / `--type` 互斥 |
|
||||
| `--widths` | string + File + Stdin(复合 JSON) | xor | 差异化列宽 map,一次调用给多列设置不同宽度:键为单列(`"A"`)或列闭区间(`"C:E"`),值为像素宽(如 80 / 120 / 200)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是 Excel 字符单位(像素 ≈ 字符数×8+16)。与 `--range` / `--width` / `--type` 互斥 |
|
||||
| `--type` | string | xor | 尺寸方式 enum:`pixel`(需配 `--width`)/ `standard`(重置为默认列宽)。常规写法直接给 `--width` 即可省略本 flag;`--type standard` 不能与 `--width` 同时给(可选值:`pixel` / `standard`) |
|
||||
| `--range` | string | xor | 要调整列宽的列闭区间;列字母如 `A:E` 或单列 `C`。统一尺寸形态必填(配 `--width` 或 `--type`);map 形态(`--widths`)不传 |
|
||||
|
||||
@@ -242,7 +242,7 @@ lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100"
|
||||
行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。两种形态:
|
||||
|
||||
- **统一尺寸**:`--range` + `--height`/`--width <px>`(省略 `--type`,等价于 `--type pixel`)。非像素模式走 `--type standard` / `--type auto`,此时不能再带像素值。
|
||||
- **差异化尺寸**:`--heights`/`--widths` 一个 JSON map,键为单行/列或闭区间、值为像素或模式字符串,**一次调用原子完成多行 / 多列不同尺寸**——不要拆多次调用,也不要用 `+batch-update`。
|
||||
- **差异化尺寸**:`--heights`/`--widths` 一个 JSON map,键为单行/列或闭区间、值为像素或模式字符串,**一次调用完成多行 / 多列不同尺寸**——不要拆多次调用,也不要用 `+batch-update`。
|
||||
|
||||
```bash
|
||||
# 统一尺寸:把第 2-10 行设为固定 30 px
|
||||
@@ -292,6 +292,6 @@ lark-cli sheets +range-sort --url "..." --sheet-id "$SID" --range "A1:E100" --ha
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内;`+rows-resize` / `+cols-resize` 两种形态二选一——统一形态必须给 `--range` 且至少给 `--height`/`--width` 或 `--type` 之一(`--type standard`/`auto` 不能与像素 flag 同给,`--type pixel` 共存 OK),map 形态(`--heights`/`--widths`)不能与 `--range`/`--height`/`--width`/`--type` 混用,map 键必须与命令维度一致(行数字 / 列字母)、不得重复,值为正整数像素或模式字符串;列宽 < 20px 拒绝(疑似 Excel 字符单位);`+cols-resize` 不接受 `auto`(列宽不支持自适应)。map 形态在 `+batch-update` 子操作里不可用(它本身就是原子批量)。
|
||||
- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内;`+rows-resize` / `+cols-resize` 两种形态二选一——统一形态必须给 `--range` 且至少给 `--height`/`--width` 或 `--type` 之一(`--type standard`/`auto` 不能与像素 flag 同给,`--type pixel` 共存 OK),map 形态(`--heights`/`--widths`)不能与 `--range`/`--height`/`--width`/`--type` 混用,map 键必须与命令维度一致(行数字 / 列字母)、不得重复,值为正整数像素或模式字符串;列宽 < 20px 拒绝(疑似 Excel 字符单位);`+cols-resize` 不接受 `auto`(列宽不支持自适应)。map 形态在 `+batch-update` 子操作里不可用(它本身就是批量提交)。
|
||||
- `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <影响范围>` 抽样比对。
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|
||||
|---------|----------------|---------|------|
|
||||
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) |
|
||||
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验。注意这与下文 `current_region` "遇表中部空行截断"不矛盾:`+table-get` 读的是子表物理 used range(飞书记录的已用矩形,含中间空行),`current_region` 是从锚点连通扩展、遇整行空行就断 |
|
||||
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验;被 `max_chars` 裁掉时该子表还会带 `truncated: true` 与 `truncation_warning`,**先看这两个字段再用数据**。注意这与下文 `current_region` "遇表中部空行截断"不矛盾:`+table-get` 读的是子表物理 used range(飞书记录的已用矩形,含中间空行),`current_region` 是从锚点连通扩展、遇整行空行就断 |
|
||||
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 |
|
||||
| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 |
|
||||
|
||||
@@ -32,7 +32,72 @@
|
||||
- 需要公式/样式/批注 → `+cells-get`
|
||||
- 只想知道某区域下拉框有哪些选项 → `+dropdown-get`
|
||||
|
||||
⚠️ **大数据优先落盘、别灌进上下文**:`+csv-get` / `+cells-get` 都受调用方 Bash / 终端的单命令 stdout 输出上限约束(常见默认约 30000 字符,超过会被截断或转存为文件)。纯值分析优先 `+csv-get --format csv` 按 `--range` 行窗口(`A1:Z500` / `A501:Z1000` …)分批重定向到文件 + 本地脚本处理 + `+csv-put` 分批回写;若确实要让结果直接进上下文又不想触发转存,给任一命令把 `--max-chars`(默认 500000)调小到略低于该上限(如 `25000`),CLI 改为优雅截断 + `has_more` 分页。
|
||||
## 读表理解脚本(Agent 优先入口)
|
||||
|
||||
当目标是"先理解表格内容 / 结构 / 子表边界",且本地存在 `scripts/lark_*.py`(只随仓库版 skill 分发,二进制内嵌版不含 `scripts/`),可优先用这组只读脚本,再决定是否直接调用上述 shortcut。脚本是可选捷径,不是必经入口——脚本不可用时直接按下表右列的 CLI 等价路径执行:如果任务很小,或需要公式 / 样式 / 批注 / 精确原始值等脚本未覆盖的信息,可以直接用 CLI 做等价或更精细读取。
|
||||
|
||||
| 脚本 | 底层 shortcut | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| `scripts/lark_inspect_workbook.py` | `+workbook-info` / `+sheet-info` / `+csv-get` | 在线表格第一步预检:拿 sheet 清单、布局、预览、`current_region` |
|
||||
| `scripts/lark_detect_subtables.py` | `+workbook-info` / `+sheet-info --include merges,hidden_rows,hidden_cols` / 小窗口 `+csv-get` | 同一 sheet 可能有多个表格区域、汇总块、备注块时,在**已知且未截断的窗口**内识别候选子表 range |
|
||||
| `scripts/lark_profile_table.py` | `+csv-get` / `+sheet-info --include hidden_rows,hidden_cols`(默认包含隐藏行列时;必要时再手工 `+cells-get` / `+table-get`) | 对**已确认且未截断的候选 range**做表头、数据范围、列类型、特殊行画像,并输出 `summary` / `field_map` / `risk_warnings` / `write_hints` |
|
||||
|
||||
`lark_profile_table.py` 是**启发式画像**,不是最终判定器:它能降低手工数行列和漏看特殊行的风险,但表头、多行标题、数据末行、列类型、特殊行和追加列都可能需要二次确认。批量写入、公式、排序、筛选、去重、透视/图表等操作前,不能只凭 profile 结果直接写;必须把 profile 输出与任务语义、样本值、必要的 CLI 补读一起核对。
|
||||
|
||||
`lark_profile_table.py` 的使用口径:
|
||||
|
||||
| 任务类型 | 建议 |
|
||||
| --- | --- |
|
||||
| 只读取或修改用户明确指定的单个单元格 / 很小范围,且不需要理解整表 | 可直接用 CLI |
|
||||
| 批量写入、公式 / 计算、排序、筛选、删除、仅保留、去重、lookup / 匹配、条件高亮、透视表、图表、汇总 | 优先对目标区域运行 `lark_profile_table.py`;若已用等价 CLI 明确确认表头、数据范围、字段列、列类型和特殊行,可跳过脚本。去重 / lookup 若目标列含 `long_numeric_like_id`、前导 0 或格式化数字,profile 只能定位列,比较值必须改用 `+cells-get` 或 `+table-get` |
|
||||
| 多块表、表头不确定、存在合并 / 汇总 / 空行 / 备注块、选区是单格但任务语义是整表 | 先 `lark_detect_subtables.py` 或补充 CLI 确认候选范围,再对目标 range 跑 `lark_profile_table.py` |
|
||||
| 需要公式、样式、批注、数据验证、精确原始值、长数字 ID 精确比较 | 先用脚本形成结构化理解,再按需补 `+cells-get` / `+table-get` / 分批 `+csv-get` |
|
||||
|
||||
推荐链路(大表先定窗口,脚本不接受截断结果):
|
||||
|
||||
```bash
|
||||
python scripts/lark_inspect_workbook.py --url "<表格URL>"
|
||||
# 先用 +workbook-info 和小窗口 +csv-get 确认真实 sheet、列边界和起始区域;大表按行窗口推进。
|
||||
python scripts/lark_detect_subtables.py --url "<表格URL>" --sheet-name "<子表名>" --range "A1:H200"
|
||||
python scripts/lark_profile_table.py --url "<表格URL>" --sheet-name "<子表名>" --range "A1:H200"
|
||||
```
|
||||
|
||||
`lark_detect_subtables.py` / `lark_profile_table.py` 的 `+csv-get` 命中 `has_more` 会以错误退出并报告已读取的 `actual_range`,绝不基于半截数据给出候选范围或画像。遇到此错误,以 `actual_range` 为已完成窗口,缩小列数或从其末行之后继续读;跨窗口的候选范围、汇总行和写入落点必须再用 CLI 核对,不能把单个窗口结果当整表结论。
|
||||
|
||||
脚本只读,不做任何写入。它们的输出用于降低 token 和定位错误;后续需要公式、样式、批注、精确原始值时,仍按本文件规则直接调用 `+cells-get` / `+table-get` / `+csv-get`。写入前如果使用了 `lark_profile_table.py`,至少读取并使用这些字段:`summary.header_row`、`summary.data_range`、`summary.data_row_segments`、`field_map`、`risk_warnings`、`visibility`、`write_hints.safe_append_col` 和 `special_rows`。仅当 `risk_warnings` 不含 `data_range_has_gaps` 时,才可把 `data_range` 当连续写入范围;有缺口时按 `data_row_segments` 分段读写。
|
||||
|
||||
脚本关键 flag:
|
||||
|
||||
| Flag | 脚本 / 默认 | 何时调整 |
|
||||
| --- | --- | --- |
|
||||
| `--skip-hidden` | profile / detect,关闭(默认包含隐藏行列) | 只分析可见数据时开启;此时必须使用 profile 的 `data_row_segments`,不要把连续 `data_range` 直接用于写入。 |
|
||||
| `--max-chars` | inspect `8000`;profile / detect `25000` | 输出过大时缩小范围或降低值;profile / detect 若截断会报错并给 `actual_range`,按窗口继续。 |
|
||||
| `--header-scan-rows` | profile `20` | 表头前有多行标题、说明或空行时提高;过大时结合 `possible_multi_row_header` 补读确认,不要仅凭评分结果写入。 |
|
||||
| `--max-sheets` | inspect `3` | 未指定 sheet 时仅前 N 个 sheet 带 layout / preview,其余仍返回摘要并在 warnings 说明。 |
|
||||
| `--max-merge-components` | detect `2000` | 超限会跳过 gap 合并并告警;需缩小窗口或人工复核子表边界。 |
|
||||
| `--gap-rows` / `--gap-cols` | detect `1` / `0` | 子表被切碎或粘连时调整;每次调整后复核候选范围。 |
|
||||
|
||||
detect 最多确认 10 个跨窗口合并锚点;超限会在 `warnings` 中说明跳过的数量。遇到该 warning,缩小扫描窗口后再复核受影响的子表边界。
|
||||
|
||||
`lark_profile_table.py` 输出触发补读的规则:
|
||||
|
||||
- `risk_warnings` 非空时,不要把画像当最终事实;按下表补读或调整,不在表内的 warning 也先保守复核。
|
||||
|
||||
| Warning | 必做动作 |
|
||||
| --- | --- |
|
||||
| `mixed_value_types` / `long_numeric_like_id` / `formula_or_value_errors` | 补 `+cells-get` 或 `+table-get`,确认原始值、类型和公式。 |
|
||||
| `duplicate_headers` / `unnamed_columns` / `header_not_detected` / `header_row_not_first` / `many_empty_cells` | 补 `+csv-get` 读取表头附近和空值样本,确认真正表头与字段列。 |
|
||||
| `data_range_not_detected` / `special_rows_present` / `empty_rows_present` | 补 `+csv-get` 读取尾部和特殊行样本,确认有效数据末行。 |
|
||||
| `possible_multi_row_header` | 补读表头上下各 1-2 行;必要时 `+sheet-info --include merges` 核对跨列合并。 |
|
||||
| `hidden_rows_in_range` / `hidden_columns_in_range` | 写入前用 `+sheet-info --include hidden_rows,hidden_cols` 确认是覆盖还是跳过隐藏内容。 |
|
||||
| `data_range_has_gaps` | 不按连续 `data_range` 写;用 `summary.data_row_segments` 对每个实际读取行段单独读写。 |
|
||||
| `data_range_has_col_gaps` | 返回的列不连续(`--skip-hidden` 跳过了隐藏列);不要把 `data_range` 当连续列区写回,按 `summary.data_col_segments` 分列段处理,否则缺口右侧的值会整体错位。 |
|
||||
|
||||
- `write_hints.safe_append_col` 只是候选追加列,不代表绝对安全。新增列或覆盖区域前,必须用 `+csv-get` / `+cells-get` / `+sheet-info` 核对该列为空、没有隐藏列/公式/样式/对象依赖,且符合用户要求的落点。该字段已自动跳过隐藏列(跳过的列名列在 `write_hints.skipped_hidden_cols`)——注意 `--skip-hidden` 下隐藏列根本不出现在返回网格里,若它们正好都贴在数据右边缘,`data_range_has_col_gaps` 也不会告警,所以这层跳过是唯一的保护,别绕过它自己按「最后一列 +1」推落点。
|
||||
|
||||
⚠️ **大数据优先落盘、别灌进上下文**:`+csv-get` / `+cells-get` 都受调用方 Bash / 终端的单命令 stdout 输出上限约束(常见默认约 30000 字符,超过会被截断或转存为文件)。纯值分析优先用 `+csv-get` 按 `--range` 行窗口(`A1:Z500` / `A501:Z1000` …)分批重定向到文件 + 本地脚本处理 + `+csv-put` 分批回写;若确实要让结果直接进上下文又不想触发转存,给任一命令把 `--max-chars`(默认 500000)调小到略低于该上限(如 `25000`),CLI 改为优雅截断 + `has_more` 分页。
|
||||
|
||||
> **落盘不等于读全**:`--output-path` 只是把上限从 stdout 口径放宽到有界的 2000 万字符(读取链路非流式,该上限是内存保护),不是无限。stdout 回执带 `complete` 字段——`complete:false` 时另有 `truncated` 与提示,文件里只有半截数据;多子表读取还会给 `unread_sheets` 列出预算耗尽前没读到的子表。**拿到回执先看 `complete`,不要默认整表已落全。**
|
||||
|
||||
**`+csv-get` 返回值核心设计**:
|
||||
- `annotated_csv` — **CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀(N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV(如喂给本地脚本做解析),去前缀即可:`line.replace(/^\[row=\d+\] /, '')`。
|
||||
@@ -44,6 +109,7 @@
|
||||
|
||||
- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;两者在处理返回数据之前都必须先读 `warning_message`(上游 schema 要求先读它再用其它字段,内含定位与截断续读提示),`+cells-get` 还要用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
|
||||
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
|
||||
- 要判断单元格内容是否被行高列宽挤到显示不全(排版检查、调整行高列宽前),给 `+cells-get` 加 `--include truncation`:会按字号 / 自动换行 / 行高列宽估算并返回被截断单元格的 `isRowTruncated` / `isColTruncated`(未返回视为未截断)。有额外计算开销,仅需要时才开
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **全量读取导致上下文溢出**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
|
||||
@@ -99,8 +165,9 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
|
||||
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000),以 has_more 分页 |
|
||||
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个。`truncation` 会额外按行高列宽 / 字号 / 自动换行估算每个单元格是否被截断显示,返回 `isRowTruncated` / `isColTruncated`(有额外计算开销,仅排版检查 / 调整行高列宽前才开)(可选值:`value` / `formula` / `style` / `comment` / `data_validation` / `truncation`) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。要整表无截断直接用 --output-path 落盘(上限自动放宽到 2000 万字符——读取链路非流式,此上限是内存保护;更大就显式给 --max-chars);仅当要让结果直接进上下文、又不落盘时才调小(如 25000),按 has_more 分页。 传 0 表示「不自设上限」,等价于不传(仍是 500000 / 落盘时 2000 万),不会退回底层工具那个更小的默认截断。 |
|
||||
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限自动放宽到有界的 2000 万字符**(覆盖 --max-chars 默认),并非无限——读取链路非流式,该上限是内存保护;显式 --max-chars 优先。stdout 回执带 `complete` 字段(命中上限时另有 `truncated` 与提示),据此判断文件是否完整,不要默认整表已落全。省略时按常规把结果打到 stdout。 |
|
||||
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
|
||||
|
||||
### `+dropdown-get`
|
||||
@@ -117,8 +184,9 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000),以 has_more 分页 |
|
||||
| `--range` | string | optional | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet)。**可省略:缺省读取整个子表**(按表格实际边界裁剪,返回的 actual_range 标注实际读取范围);大表配合 --max-chars / --output-path 控制体量 |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。要整表无截断直接用 --output-path 落盘(上限自动放宽到 2000 万字符——读取链路非流式,此上限是内存保护;更大就显式给 --max-chars);仅当要让结果直接进上下文、又不落盘时才调小(如 25000),按 has_more 分页。 传 0 表示「不自设上限」,等价于不传(仍是 500000 / 落盘时 2000 万),不会退回底层工具那个更小的默认截断。 |
|
||||
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限自动放宽到有界的 2000 万字符**(覆盖 --max-chars 默认),并非无限——读取链路非流式,该上限是内存保护;显式 --max-chars 优先。stdout 回执带 `complete` 字段(命中上限时另有 `truncated` 与提示),据此判断文件是否完整,不要默认整表已落全。⚠️ 落盘的是 data 载荷的 **JSON**(`+csv-get` 也一样,CSV 文本是 JSON 里的一个字段),不是直接可用的 .csv 文件;要纯 CSV 文件请把 stdout 重定向到文件。 省略时按常规把结果打到 stdout。 |
|
||||
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
|
||||
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
|
||||
|
||||
@@ -131,6 +199,8 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
| `--sheet-id` | string | optional | 只读该子表(按 id);省略则读所有子表 |
|
||||
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
|
||||
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的完整 used range(会跨过表中部的整行空行 / 整列空列,不会被截断) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。底层工具即使不传也有约 50000 的默认截断,故此处显式发送以放宽;要整表读取请用 --output-path 落盘(上限自动放宽到有界的 2000 万字符,非无限;回执 complete 字段说明是否完整)。 传 0 表示「不自设上限」,等价于不传(仍是 500000 / 落盘时 2000 万),不会退回底层工具那个更小的默认截断。 |
|
||||
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限自动放宽到有界的 2000 万字符**(覆盖 --max-chars 默认),并非无限——读取链路非流式,该上限是内存保护;显式 --max-chars 优先。stdout 回执带 `complete` 字段(命中上限时另有 `truncated` 与提示),据此判断文件是否完整,不要默认整表已落全。省略时按常规把结果打到 stdout。 |
|
||||
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
|
||||
|
||||
## Examples
|
||||
@@ -147,6 +217,10 @@ lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet
|
||||
|
||||
# 用 sheet-name 模糊定位(运行时框架会先解析到 sheet-id)
|
||||
lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" --range "A1:F30"
|
||||
|
||||
# 全量读:省略 --range 即读整个子表(按实际边界裁剪,返回 actual_range 标注实读范围),
|
||||
# 无需先 +workbook-info 探行列再拼 range;大表配合 --max-chars / --output-path
|
||||
lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细"
|
||||
```
|
||||
|
||||
输出契约(envelope.data):
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
- 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义
|
||||
- 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容
|
||||
- 插入用 `+dim-insert`:`--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**)+ `--count`(插入数量,>0)。新行/列样式继承用 `--inherit-style`(`before`/`after`/`none`)
|
||||
- 插入用 `+dim-insert`:`--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**)+ `--count`(插入数量,>0)。新行/列样式继承用 `--inherit-style`(`before` 继承前一行/列 / `after` 继承后一行/列);它只决定继承哪一侧的样式,**插入位置始终在 `--position` 之前,不改变插入方向**。⚠️ 不传时默认继承**后一行/列**(同 `after`);底层无法插入"无格式"行/列,要真正的纯空白行/列,插入后再用 `+cells-clear --scope formats` 清除新行/列的格式。
|
||||
- 例如"在第 20 行后新增 116 行":`--position 21 --count 116`("第 20 行后"即 1-based 行号 21)
|
||||
|
||||
**区间表达统一为 A1 风格**:所有涉及"一段连续行/列"的 shortcut 都用同一套 A1 闭区间字符串语法,**不存在 inclusive / exclusive / 0-based / 1-based 跨命令差异**:
|
||||
@@ -40,7 +40,7 @@
|
||||
- **插入列直接用字母**:`+dim-insert` 的 `--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引
|
||||
- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置
|
||||
- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容(`+csv-get` / `+cells-get` 见 `lark-sheets-read-data`)
|
||||
- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before`
|
||||
- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before`。不要把 `--inherit-style after` 当成“插到 D 列右侧”,它不是插入方向参数。
|
||||
- **`+dim-move` 同维度约束**:`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用
|
||||
- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续
|
||||
- **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1
|
||||
@@ -76,7 +76,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--inherit-style` | string | optional | 新行/列样式继承策略 enum:`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认)(可选值:`before` / `after` / `none`) |
|
||||
| `--inherit-style` | string | optional | 新行/列样式继承 enum:`before`(继承前一行/列)/ `after`(继承后一行/列);不传时默认继承后一行/列(同 `after`),底层无法插入无格式行/列。只决定继承哪侧样式、不改变插入方向(始终插在 `--position` 之前);要纯空白行/列请插入后用 `+cells-clear --scope formats`(可选值:`before` / `after`) |
|
||||
| `--position` | string | required | 插入位置(在此行/列**之前**插入):行用 1-based 行号如 `3`;列用字母如 `C` |
|
||||
| `--count` | int | required | 插入数量(>0) |
|
||||
|
||||
@@ -86,7 +86,8 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C` |
|
||||
| `--range` | string | xor | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C`。与 `--ranges` 二选一 |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | xor | 要删除的多个行/列区间 JSON 数组(最多 100 个,如 `["5:5","8:8","11:13"]` 或 `["C:C","F:G"]`),全行或全列不可混用,区间不可重叠;与 `--range` 二选一。CLI 按位置**从大到小逆序**合成一次批量删除(fail-fast、不回滚)——正序删除会因前面的行/列被删导致后续索引前移错位,逆序由 CLI 代劳,无需自行排序 |
|
||||
|
||||
### `+dim-hide`
|
||||
|
||||
@@ -110,8 +111,8 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) |
|
||||
| `--count` | int | required | 冻结前 N 行/列;传 0 解除冻结 |
|
||||
| `--rows` | int | optional | 冻结前 N 行;与 --cols 一起描述完整冻结状态,省略的轴即为不冻结(0 表示不冻结行) |
|
||||
| `--cols` | int | optional | 冻结前 N 列;与 --rows 一起描述完整冻结状态,省略的轴即为不冻结(0 表示不冻结列) |
|
||||
|
||||
### `+dim-group`
|
||||
|
||||
@@ -168,6 +169,11 @@ lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "5:7" --yes
|
||||
|
||||
# 删除 D-F 列
|
||||
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "D:F" --yes
|
||||
|
||||
# 删除多个散布区间(如按查重结果删行):--ranges 一次批量交付(fail-fast、不回滚,CLI 逆序保索引)。
|
||||
# CLI 自动按位置从大到小逆序执行——正序会因前面的行被删导致后续索引前移错位;
|
||||
# 无需自行排序,也不要为此拼 +batch-update 的子操作数组
|
||||
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --ranges '["5:5","8:8","11:13"]' --yes
|
||||
```
|
||||
|
||||
### `+dim-hide` / `+dim-unhide`
|
||||
@@ -192,13 +198,18 @@ lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --t
|
||||
|
||||
> ⚠️ 这两条 shortcut 来自 `lark-sheets-range-operations` 的 `+rows-resize / +cols-resize` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `lark-sheets-range-operations.md`。
|
||||
>
|
||||
> 常规写法:行高走 `--range` + `--height <px>`、列宽走 `--range` + `--width <px>`,无需再传 `--type`(等价于 `--type pixel`);多行 / 多列不同尺寸用 map 形态 `--heights` / `--widths`(如 `--widths '{"A":100,"C:E":120}'`)一次原子完成,不要拆多次调用或走 `+batch-update`。`--type standard` / `--type auto` 用于非像素模式,不能与像素 flag 同给。`+cols-resize.--type` 不接受 `auto`(列宽不支持自动适应)。⚠️ 单位是像素(不是 Excel 字符单位 / 磅)。
|
||||
> 常规写法:行高走 `--range` + `--height <px>`、列宽走 `--range` + `--width <px>`,无需再传 `--type`(等价于 `--type pixel`);多行 / 多列不同尺寸用 map 形态 `--heights` / `--widths`(如 `--widths '{"A":100,"C:E":120}'`)一次调用完成,不要拆多次调用或走 `+batch-update`。`--type standard` / `--type auto` 用于非像素模式,不能与像素 flag 同给。`+cols-resize.--type` 不接受 `auto`(列宽不支持自动适应)。⚠️ 单位是像素(不是 Excel 字符单位 / 磅)。
|
||||
|
||||
### `+dim-freeze`
|
||||
|
||||
冻结是**整份状态覆盖**、不是按轴叠加:`--rows` / `--cols` 一起描述完整的目标状态,没写的轴即为不冻结。所以要同时冻住行和列必须一次给全,拆成两次调用只会剩下最后一次的那个轴。
|
||||
|
||||
```bash
|
||||
# 冻结前 1 行(--count 传 0 解除冻结)
|
||||
lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --count 1
|
||||
# 冻结前 1 行 + 前 2 列(一次给全)
|
||||
lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --rows 1 --cols 2
|
||||
|
||||
# 解除行冻结但保住列:把要保留的轴一并写出
|
||||
lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --rows 0 --cols 2
|
||||
```
|
||||
|
||||
### `+dim-group` / `+dim-ungroup`(大纲)
|
||||
@@ -207,6 +218,6 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `lark-sheets-range-operations.md`。
|
||||
- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-freeze` 至少给 `--rows` / `--cols` 之一;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`,`--range` 与 `--ranges` 二选一、`--ranges` 各区间同维度且不可重叠(≤100 个);`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `lark-sheets-range-operations.md`。
|
||||
- `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。
|
||||
|
||||
93
skills/lark-sheets/references/lark-sheets-styles-put.md
Normal file
93
skills/lark-sheets/references/lark-sheets-styles-put.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Lark Sheet Styles Put(+styles-put)
|
||||
|
||||
> **本文定位**:对**已有**表格做美化收尾的默认入口——样式 / 边框 / 合并 / 行高列宽 / 冻结写成一份声明式规格,一次调用交付。样式**取什么值**(配色 / 字号 / 对齐 / 数字格式标准)以 `lark-sheets-visual-standards` 为唯一权威,本文只讲**怎么落地**。
|
||||
>
|
||||
> **边界(三分流判定,按操作组合选入口)**:目标是**样式 / 合并 / 行高列宽 / 冻结**的任意组合 → 本命令;**同一个写操作**打多个区域(如多区域清除、批量下拉)→ 用该命令自身的复数形态(`--ranges` / map 入参);操作链**跨类型且有顺序依赖**(如插列 → 写表头 → 回填数据)→ `+batch-update`。美化收尾不需要也不应该拼 `--operations` 子操作数组。
|
||||
|
||||
## 使用场景
|
||||
|
||||
写入。对存量表格的多个子表批量应用视觉规格:新表美化、加汇总行后统一版式、按分组合并同类单元格、调列宽行高、冻结表头。整份规格展开为一次批量提交按序执行,与 `+batch-update` 同为 **fail-fast 且不回滚**——失败时已执行的子操作保留生效。
|
||||
|
||||
⚠️ **失败后不要照抄报错里的 `operations[N]` 去续发**:那个数组是 CLI 从 `--styles` 展开出来的(相邻同样式的 `cell_styles` 还会被合并成更大的矩形),下标与你写的 spec 项没有对应关系,也不是你能直接重发的东西。正确做法:回读受影响区域(`+cells-get --include style` / `+sheet-info`)确认哪些已生效,再重发没落上的部分。样式 / 行高列宽 / 冻结是幂等盖章(整份重发无副作用,这通常就是最省事的解法),只有 `cell_merges` 需要挑出未生效的部分单独发。
|
||||
|
||||
**词汇三处同构**:`--styles` 的字段词汇与 `+workbook-create --styles`(建新表同步美化)、`+table-put --styles`(写数据同步美化)完全一致——`cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze` 学一次三处通用。区别只有两点:本命令作用于**已有**表格(顶层 `--url` / `--spreadsheet-token` 定位),且 `cell_styles` 的 range 不受「本次写入区域」限制、可指向表内任意区域。
|
||||
|
||||
**规格要点**:
|
||||
|
||||
- 顶层 `{styles:[...]}`,每项对应一个目标子表,`name` 必须是真实子表名(不确定先 `+workbook-info` 查,禁止猜 `Sheet1`)。
|
||||
- 每个子表项按固定顺序执行:`cell_merges` → `cell_styles` → `row_sizes` → `col_sizes` → `freeze`;样式盖章允许覆盖含合并区的区域(合并区限制只针对值写入,样式不受限)。
|
||||
- `row_sizes` / `col_sizes` 只需 `{range, size}`(px,即像素尺寸;`standard` / 行的 `auto` 才需显式 `type`)。尺寸键统一是 `size`。
|
||||
- 加边框用 `border` 简写:`{"style":"solid","color":"#DDDDDD"}` 应用到四边;只有分侧不同样式才用 `border_styles` 完整形态。
|
||||
- `freeze` 用 `{rows:N, cols:N}` 冻结前 N 行 / 列,0 或省略表示该维度不冻结;rows / cols 至少一个要 > 0(全 0 的 freeze 是无效操作,会被校验拒绝)。
|
||||
|
||||
**回读校验**:整份规格执行成功后按编辑准则抽样回读受影响区域(`+cells-get --include style` 或 `+sheet-info` 看合并 / 行高列宽 / 冻结),确认关键样式实际生效。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+styles-put` | write | 批量 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+styles-put`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--styles` | string + File + Stdin(复合 JSON) | required | 对**已有**表格应用的视觉规格 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表(`name` 用真实子表名),并至少给 `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze` 之一。字段词汇与 `+workbook-create` / `+table-put` 的 `--styles` 完全同构(cell_styles 用 A1 range + 扁平样式字段,边框用 `border` 简写 {style,weight,color} 四边同款、分侧才用 border_styles;row/col sizes 用行/列范围 + size(px 即像素,standard/auto 才需 type);merges 用单元格 range;freeze 用 `{rows:N, cols:N}` 冻结前 N 行/列)。整份规格展开为一次批量提交(fail-fast、不回滚:失败时已生效的子操作保留);range 不受「本次写入区域」限制,可指向表内任意区域 |
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+styles-put` `--styles`
|
||||
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校… { cols?: integer, rows?: integer }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
### `+styles-put`
|
||||
|
||||
表头美化 + 按组合并 + 列宽 + 冻结首行,一次交付:
|
||||
|
||||
```bash
|
||||
lark-cli sheets +styles-put --url "https://example.feishu.cn/sheets/shtXXX" --styles - <<'JSON'
|
||||
{"styles":[{
|
||||
"name": "Sheet1",
|
||||
"cell_merges": [{"range":"A5:A8"},{"range":"A9:A12"}],
|
||||
"cell_styles": [
|
||||
{"range":"A1:F1","font_weight":"bold","background_color":"#1E5BC6","font_color":"#FFFFFF","horizontal_alignment":"center"},
|
||||
{"range":"A2:F30","border":{"style":"solid","color":"#DDDDDD"}}
|
||||
],
|
||||
"row_sizes": [{"range":"1:1","size":36}],
|
||||
"col_sizes": [{"range":"A:C","size":120}],
|
||||
"freeze": {"rows":1}
|
||||
}]}
|
||||
JSON
|
||||
```
|
||||
|
||||
多子表同一批交付(每个子表一个 styles 项):
|
||||
|
||||
```bash
|
||||
lark-cli sheets +styles-put --url "..." --styles - <<'JSON'
|
||||
{"styles":[
|
||||
{"name":"明细","cell_styles":[{"range":"A1:H1","font_weight":"bold","background_color":"#F0F0F0"}],"freeze":{"rows":1}},
|
||||
{"name":"汇总","cell_styles":[{"range":"A1:D1","font_weight":"bold"}],"col_sizes":[{"range":"A:D","type":"pixel","size":140}]}
|
||||
]}
|
||||
JSON
|
||||
```
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:`--styles` 必须是合法 JSON、`styles` 非空数组;每项 `name` 必填、至少给 `cell_merges` / `cell_styles` / `row_sizes` / `col_sizes` / `freeze` 之一;`cell_styles` 每项至少一个样式字段;展开后受子操作数(100)与总格数预算约束,超限报错给拆分建议。
|
||||
- `DryRun`:输出展开后每个子操作的请求模板,不发起调用。
|
||||
- `Execute`:整份规格合成一次批量请求按序执行;fail-fast 且不回滚。报错会列出失败的子操作及原因,但其中的 `operations[N]` 是 CLI 展开后的内部下标(含 `cell_styles` 合并),不对应 `--styles` 里的项,也不能直接按下标续发——报错会明说这一点并让你先回读再补发。
|
||||
@@ -64,7 +64,7 @@
|
||||
- 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。
|
||||
- **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。
|
||||
- 具体采样与复制流程见下方「场景二:从已有区域继承美化」。
|
||||
- **列宽 / 行高调整**(飞书 `+cols-resize` / `+rows-resize` 直接给像素值:统一尺寸用 `--range` + `--width`/`--height <px>`,多列 / 多行不同尺寸用 `--widths`/`--heights` map 一次原子完成,如 `--widths '{"A":100,"C:E":120}'`):
|
||||
- **列宽 / 行高调整**(飞书 `+cols-resize` / `+rows-resize` 直接给像素值:统一尺寸用 `--range` + `--width`/`--height <px>`,多列 / 多行不同尺寸用 `--widths`/`--heights` map 一次调用完成,如 `--widths '{"A":100,"C:E":120}'`):
|
||||
- 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。
|
||||
- 经验估算:中文每字约 15-18px,英文/数字每字约 7-9px,外加 10-16px padding。
|
||||
- 上下限建议 80~400px;超上限启用自动换行(`word_wrap: auto-wrap`)+ 调整行高,而非无限加宽。
|
||||
@@ -155,7 +155,7 @@ Step 1 — 格式铺开:`+batch-update` + `+range-copy`(或 `+range-fill`)
|
||||
Step 2 — 内容覆写:`+batch-update` + `+cells-set`(仅传 value/formula,不传任何样式)
|
||||
└── 将每行的实际数据写入,cell_styles 全部省略,因为格式已在 Step 1 中就位
|
||||
|
||||
Step 3 — 微调收尾:`+rows-resize --heights` / `+cols-resize --widths`(行高列宽 map 一次原子完成)、`+batch-update` + `+cells-{merge|unmerge}` 等
|
||||
Step 3 — 微调收尾:`+rows-resize --heights` / `+cols-resize --widths`(行高列宽 map 一次调用完成)、`+batch-update` + `+cells-{merge|unmerge}` 等
|
||||
└── 调整行高列宽、处理合并单元格、扩展条件格式范围等边缘情况
|
||||
```
|
||||
|
||||
|
||||
@@ -197,10 +197,11 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校… { cols?: integer, rows?: integer }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与准则;选哪个 shortcut 见上方「使用场景」。
|
||||
|
||||
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas` 的 `--cells`。
|
||||
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`--cells` 恒为二维数组(行 × 格),单格也是 `[[{"value":…}]]`;且行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas` 的 `--cells`。
|
||||
|
||||
> **单元格图片 vs 浮动图片(最易选错)**:图若**属于某条记录、要随那行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ **单元格图片**(本工具):用 `+cells-set-image`(最短)或 `+cells-set` 的 `rich_text` + `type: "embed-image"`。只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片,见 lark-sheets-float-image。别因「浮动图更好控制 / 更熟」默认选浮动图——它承载"对应某记录"的图会随增删行 / 排序错位。
|
||||
|
||||
@@ -89,6 +89,19 @@
|
||||
|
||||
⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。
|
||||
|
||||
💡 **多个不连续区域写入(批量修公式的正解)**:散布多处(可跨 sheet)的值 / 公式写入,用 `--writes` 一次批量交付(fail-fast、不回滚)——每项 `{sheet_name, range, cells}`(sheet 定位必须写在每项里),不要为此拼 `+batch-update` 的 `--operations`,也不要逐区域多次调用(多次往返、中途失败难恢复):
|
||||
|
||||
```bash
|
||||
lark-cli sheets +cells-set --url "..." --writes - <<'JSON'
|
||||
[
|
||||
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
|
||||
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(明细!C:C)"}]]}
|
||||
]
|
||||
JSON
|
||||
```
|
||||
|
||||
范围级统一样式不在 `--writes` 里做(cells 逐格 `cell_styles` 仅用于逐格差异化),写完接 `+styles-put`。
|
||||
|
||||
💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。
|
||||
|
||||
💡 **内容与样式分离写入(推荐)**:当需要同时写入内容和样式时,`cells` 中每个单元格都带上 `cell_styles` / `border_styles` 会导致入参非常冗长。由于同一区域的样式通常高度重复(如整列统一背景色、统一边框),推荐拆成两步:
|
||||
@@ -102,7 +115,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl
|
||||
```
|
||||
这比在 99 个单元格中都重复写样式 JSON 高效得多。
|
||||
|
||||
💡 **样式更新是「部分合并」,不是整体覆盖**:`+cells-set-style` / `+cells-batch-set-style`(以及 `+cells-set` 的 `cell_styles` / `border_styles`)只改你**显式传入**的样式属性,未传的属性保留原值。两个实用推论:
|
||||
💡 **样式更新是「部分合并」,不是整体覆盖**:`+cells-set-style` / `+styles-put`(以及 `+cells-set` 的 `cell_styles` / `border_styles`)只改你**显式传入**的样式属性,未传的属性保留原值。两个实用推论:
|
||||
- **可分层叠加**:对同一区域先刷字体色、再单独刷背景色、再单独刷边框,后一步不会清掉前一步——美化已有区域时无需一次带齐所有字段,可拆成多次窄调用。
|
||||
- **`border_styles` 按边合并**:只传 `{"top":{...}}` 只更新上边框,`bottom` / `left` / `right` 保留原状;不必为了「只改一条边」而把四边全部重传。(例外见上方「新增行的边框/样式禁止用 `{}` 跳过」:**全新行**底子里没有边框,仍需把要显示的边都显式传出。)
|
||||
|
||||
@@ -236,7 +249,7 @@ lark-cli sheets +dropdown-set \
|
||||
|
||||
> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet)。注意一个坑:回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`,**必须自己重新补上 sheet 前缀**,否则会被拒。
|
||||
>
|
||||
> ⚠️ **`--ranges` 类批量 flag 的 sheet 前缀必须「裸写」**——`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` / `+dropdown-delete` 的 `--ranges` 解析器不接受引号:表名含点或空格(如 `2025.9`、`一月份`)也直接写 `2025.9!A1`,写成 `'2025.9'!A1` 会被当成表名一部分、报 `sheet not found`。**但 `--source-range`、透视表 `--source`、`--range` 走 A1 标准**:sheet 名带单引号(如 `'Sheet1'!A1:B2`)是标准写法、裸写也接受,回读统一返回带引号形式——别把 `--ranges` 的裸写要求套到这些 flag 上。
|
||||
> ⚠️ **`--ranges` 类批量 flag 的 sheet 前缀必须「裸写」**——`+cells-batch-clear` / `+dropdown-update` / `+dropdown-delete` 的 `--ranges` 解析器不接受引号:表名含点或空格(如 `2025.9`、`一月份`)也直接写 `2025.9!A1`,写成 `'2025.9'!A1` 会被当成表名一部分、报 `sheet not found`。**但 `--source-range`、透视表 `--source`、`--range` 走 A1 标准**:sheet 名带单引号(如 `'Sheet1'!A1:B2`)是标准写法、裸写也接受,回读统一返回带引号形式——别把 `--ranges` 的裸写要求套到这些 flag 上。
|
||||
|
||||
`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。
|
||||
|
||||
@@ -259,8 +272,9 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | 写入区域(A1 格式) |
|
||||
| `--cells` | string + File + Stdin(复合 JSON) | required | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` |
|
||||
| `--range` | string | xor | 写入区域(A1 格式)。与 `--writes` 二选一(单区域用 --range+--cells,多区域用 --writes) |
|
||||
| `--cells` | string + File + Stdin(复合 JSON) | xor | JSON:2D 数组 `[[{cell},...],...]`,维度与 `--range` 完全一致;每个 cell 可含 `value` / `formula` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等,完整字段跑 `--print-schema` |
|
||||
| `--writes` | string + File + Stdin(复合 JSON) | xor | 多区域写入 JSON 数组(最多 100 项),每项 `{sheet_name\|sheet_id, range, cells}`——**sheet 定位必须写在每项里**(与 +batch-update 子操作、+styles-put 项同惯例,不认顶层 --sheet-name),cells 结构同 `--cells`(二维数组,可逐格带 cell_styles/border_styles)。整批展开为**单次批量提交**(fail-fast、不回滚),支持跨 sheet;典型场景:批量修复散布多处的公式、跨表同构写入——不要为此拼 +batch-update 的 --operations。与 `--range`+`--cells` 二选一;范围级统一样式不在此做,写完接 +styles-put |
|
||||
| `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 |
|
||||
| `--max-cells` | int | optional | 防爆,默认 50000(隐藏 flag:不在 `--help` 列出,但可正常传入) |
|
||||
| `--copy-to-range` | string | optional | 复制范围(A1 表示法):把 --range 中 --cells 写入的内容(值/公式/样式,取决于实际传入字段)复制到该区域,公式引用自动平移(如 C2=B2 → C3=B3)。适合先写一行/一块模板再扩展填充整列/整区域(如 --range A1:G1 写模板、--copy-to-range A1:G100 填充 100 行)。支持整行 3:6、整列 C:E、到列尾 D3:D、到行尾 D3:3;支持英文逗号分隔多个目标区域,如 C1:D2,E5:F6 |
|
||||
@@ -283,7 +297,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) |
|
||||
| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) |
|
||||
| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) |
|
||||
| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON:`{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`;4 方向结构相同 |
|
||||
| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON:`{ top: {style,weight,color}, bottom: ..., left: ..., right: ... }`;4 方向结构相同。style = 线型(solid\|dashed\|dotted\|double\|none);weight = 粗细(thin\|medium\|thick —— 字符串,不是像素数字);color = 十六进制如 #000000。`{ all: {...} }` 一次设置四边。边框只有这一个 flag:不存在 --border-all / --border-top / --border-color |
|
||||
|
||||
### `+cells-set-image`
|
||||
|
||||
@@ -346,6 +360,16 @@ _【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]]
|
||||
- `multiple_values` (array<object>?) — 多值内容,用于支持多选的列表验证单元格 each: { value: oneOf, format?: string }
|
||||
- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array<string>, range?: string, operator?: enum, values?: array<oneOf>, …共 9 项 }
|
||||
|
||||
### `+cells-set` `--writes`
|
||||
|
||||
_多区域写入项数组(最多 100 项),整批单次批量提交(fail-fast、不回滚);支持跨 sheet_
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `sheet_id` (string?) — 目标子表 reference_id;与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)
|
||||
- `sheet_name` (string?) — 目标子表名;与 sheet_id 二选一,必须写在每一项里
|
||||
- `range` (string) — A1 矩形范围,行列维度必须与 cells 严格一致(同 --range)
|
||||
- `cells` (array) — 二维单元格数组,结构同 --cells(value / formula / cell_styles / border_styles 等,见 set_cell_…
|
||||
|
||||
### `+cells-set-style` `--border-styles`
|
||||
|
||||
_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_
|
||||
@@ -383,10 +407,11 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结;rows / cols 至少一个要 > 0,全 0 会被校… { cols?: integer, rows?: integer }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -400,8 +425,8 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
|
||||
|---------|--------|--------|
|
||||
| 只改**已有 cell 的样式**,不动 value/formula | `+cells-set-style` | `+cells-set`(会触发不必要的值写入) |
|
||||
| 把**单张图片嵌入**到某个 cell | `+cells-set-image` | `+cells-set`(参数更繁琐) |
|
||||
| **插行/列 + 写入** 这种多步组合,且要原子 | `+batch-update`(见 lark-sheets-batch-update) | 多次独立 `+cells-set`(非原子;插入会扰动后续 range) |
|
||||
| 在**多个不连续 range** 上应用同一组样式 | `+cells-batch-set-style`(见 lark-sheets-batch-update) | 多次 `+cells-set-style`(非原子) |
|
||||
| **插行/列 + 写入** 这种多步组合,且要一次交付 | `+batch-update`(见 lark-sheets-batch-update) | 多次独立 `+cells-set`(插入会扰动后续调用的 range) |
|
||||
| 在**多个不连续 range** 上应用同一组样式 | `+styles-put`(cell_styles 多项即多区域,见 lark-sheets-styles-put) | 多次 `+cells-set-style`(多次往返) |
|
||||
|
||||
### `+cells-set`
|
||||
|
||||
@@ -422,7 +447,7 @@ lark-cli sheets +cells-set --spreadsheet-token shtXXX --sheet-id "$SID" \
|
||||
|
||||
> 中间想跳过的 cell 用空对象 `{}` 占位(底层语义为"保留原值不变"),`--cells` 维度仍须与 `--range` 完全一致。例:`--range A1:A5 --cells '[[{"value":1}],[{}],[{}],[{}],[{"value":5}]]'` 只写 A1 和 A5。
|
||||
>
|
||||
> 跨多个不连续区域散点写入(如 `D2` + `F7` + `J15`)不属于 `+cells-set` 的能力范围——请用 `+batch-update` 把多次 `+cells-set` 打包成单次原子请求。
|
||||
> 跨多个不连续区域散点写入(如 `D2` + `F7` + `J15`)超出单次 `--range` + `--cells` 的范围,但**仍在 `+cells-set` 之内**:用本命令的 `--writes` 复数形态一次批量交付(每项 `{sheet_name, range, cells}`,可跨 sheet,见上方「多个不连续区域写入」)。**不要为此拼 `+batch-update` 的 `--operations`**——那是给跨类型、有顺序依赖的操作链用的。
|
||||
|
||||
### `+cells-set-style`
|
||||
|
||||
@@ -511,6 +536,8 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
|
||||
python export.py | lark-cli sheets +table-put --url "<表URL>" --sheets -
|
||||
# 某 sheet 带 "mode":"append" 追加到已有数据末尾、默认不重复表头
|
||||
lark-cli sheets +table-put --spreadsheet-token "<token>" --sheets @payload.json
|
||||
# --sheets 与 --styles 都是大 JSON 时:stdin 每次调用只能给一个 flag,一个走 -、另一个走 @cwd 相对路径
|
||||
lark-cli sheets +table-put --url "<表URL>" --sheets - --styles @styles.json < sheets.json
|
||||
```
|
||||
|
||||
每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`。
|
||||
|
||||
593
skills/lark-sheets/scripts/lark_detect_subtables.py
Normal file
593
skills/lark-sheets/scripts/lark_detect_subtables.py
Normal file
@@ -0,0 +1,593 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Detect occupied subtable regions in a Lark sheet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from lark_sheet_range import RangeBounds, col_to_index, format_range, index_to_col, parse_range, range_union
|
||||
from lark_sheet_read_cli import (
|
||||
LarkCliError,
|
||||
add_spreadsheet_args,
|
||||
emit_error,
|
||||
emit_success,
|
||||
envelope_data,
|
||||
resolve_target_sheets,
|
||||
run_sheets,
|
||||
sheet_identifier,
|
||||
sheet_locator,
|
||||
sheet_title,
|
||||
)
|
||||
|
||||
ACTION = "detect_subtables"
|
||||
ROW_PREFIX_RE = re.compile(r"^\[row=(\d+)\]\s?(.*)$")
|
||||
MAX_EXTERNAL_MERGE_ANCHOR_CHECKS = 10
|
||||
|
||||
|
||||
def _inside_quoted_field(lines: list[str]) -> bool:
|
||||
"""True when the accumulated record has an unterminated quoted field.
|
||||
|
||||
RFC 4180 escapes a literal quote by doubling it, so both halves of a `""`
|
||||
pair count and parity still tracks whether a field is left open. A record
|
||||
that is still open must swallow the next physical line verbatim — even one
|
||||
that looks like a `[row=N]` prefix, because inside quotes that text is
|
||||
ordinary cell content, not a new record.
|
||||
"""
|
||||
return sum(line.count('"') for line in lines) % 2 == 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class CsvGrid:
|
||||
row_numbers: list[int]
|
||||
col_letters: list[str]
|
||||
values: list[list[str]]
|
||||
row_numbers_inferred: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Component:
|
||||
bounds: RangeBounds
|
||||
occupied_count: int
|
||||
|
||||
|
||||
def parse_annotated_csv(
|
||||
text: str,
|
||||
col_indices: list[str] | None = None,
|
||||
row_indices: list[int] | None = None,
|
||||
source_range: str | None = None,
|
||||
) -> CsvGrid:
|
||||
row_numbers: list[int] = []
|
||||
values: list[list[str]] = []
|
||||
max_cols = 0
|
||||
lines = (text or "").splitlines()
|
||||
row_numbers_inferred = False
|
||||
has_authoritative_rows = isinstance(row_indices, list) and len(row_indices) > 0
|
||||
|
||||
first_meaningful = next((line for line in lines if line.strip()), "")
|
||||
if ROW_PREFIX_RE.match(first_meaningful):
|
||||
records = []
|
||||
current_lines: list[str] | None = None
|
||||
current_row_number: int | None = None
|
||||
for line in lines:
|
||||
match = ROW_PREFIX_RE.match(line)
|
||||
if match and current_lines is not None and _inside_quoted_field(current_lines):
|
||||
# Prefix-looking text inside an open quoted field is content.
|
||||
current_lines.append(line)
|
||||
continue
|
||||
if match:
|
||||
if current_lines is not None and current_row_number is not None:
|
||||
records.append("\n".join(current_lines))
|
||||
row_numbers.append(current_row_number)
|
||||
current_row_number = int(match.group(1))
|
||||
current_lines = [match.group(2)]
|
||||
elif current_lines is not None:
|
||||
current_lines.append(line)
|
||||
if current_lines is not None and current_row_number is not None:
|
||||
records.append("\n".join(current_lines))
|
||||
row_numbers.append(current_row_number)
|
||||
|
||||
# Cross-check against the row numbers the server itself reported.
|
||||
# _inside_quoted_field decides whether a "[row=N]" line starts a new
|
||||
# record or is content inside an open quoted field; when the payload's
|
||||
# quoting is malformed (a lone unescaped quote in a cell), that call
|
||||
# goes the wrong way and every following line is swallowed into the
|
||||
# previous cell — the rows simply vanish, and everything downstream
|
||||
# (data_range, last data row, column profiles) is quietly computed from
|
||||
# a short grid. row_indices is authoritative and already in hand, so
|
||||
# refuse rather than profile a grid that does not match it.
|
||||
if has_authoritative_rows and row_numbers != [int(r) for r in row_indices]:
|
||||
raise ValueError(
|
||||
"annotated_csv did not parse into the rows the server reported "
|
||||
f"(parsed {len(row_numbers)} rows {row_numbers[:5]}…, expected "
|
||||
f"{len(row_indices)} rows {list(row_indices)[:5]}…) — most likely "
|
||||
"an unbalanced quote in a cell. Re-read a narrower --range, or use "
|
||||
"+cells-get for this region instead of the CSV path."
|
||||
)
|
||||
|
||||
for record in records:
|
||||
parsed = next(csv.reader([record]))
|
||||
values.append(parsed)
|
||||
max_cols = max(max_cols, len(parsed))
|
||||
else:
|
||||
reader = csv.reader(io.StringIO(text or ""))
|
||||
fallback_start = 1
|
||||
if source_range:
|
||||
fallback_start = parse_range(
|
||||
source_range,
|
||||
max_row=1_048_576,
|
||||
max_col=18_278,
|
||||
).start_row
|
||||
for offset, row in enumerate(reader):
|
||||
row_num = None
|
||||
if has_authoritative_rows and offset < len(row_indices):
|
||||
try:
|
||||
row_num = int(row_indices[offset])
|
||||
except (TypeError, ValueError):
|
||||
# Non-numeric row index: leave row_num as None so the
|
||||
# inferred fallback numbering below takes over.
|
||||
pass
|
||||
if row_num is None:
|
||||
row_numbers_inferred = True
|
||||
row_numbers.append(row_num if row_num is not None else fallback_start + offset)
|
||||
values.append(row)
|
||||
max_cols = max(max_cols, len(row))
|
||||
|
||||
if col_indices:
|
||||
col_letters = [str(col) for col in col_indices[:max_cols]]
|
||||
while len(col_letters) < max_cols:
|
||||
next_col = col_to_index(col_letters[-1]) + 1 if col_letters else len(col_letters) + 1
|
||||
col_letters.append(index_to_col(next_col))
|
||||
else:
|
||||
col_letters = [index_to_col(i) for i in range(1, max_cols + 1)]
|
||||
|
||||
for row in values:
|
||||
row.extend([""] * (len(col_letters) - len(row)))
|
||||
inferred = bool(values) and not ROW_PREFIX_RE.match(first_meaningful) and (
|
||||
row_numbers_inferred or not has_authoritative_rows
|
||||
)
|
||||
return CsvGrid(
|
||||
row_numbers=row_numbers,
|
||||
col_letters=col_letters,
|
||||
values=values,
|
||||
row_numbers_inferred=inferred,
|
||||
)
|
||||
|
||||
|
||||
def _merged_ranges(layout: dict[str, Any]) -> list[str]:
|
||||
merges = layout.get("merged_cells") or layout.get("merges") or []
|
||||
result = []
|
||||
for item in merges:
|
||||
if isinstance(item, str):
|
||||
result.append(item)
|
||||
elif isinstance(item, dict):
|
||||
value = item.get("range") or item.get("a1_range") or item.get("range_ref")
|
||||
if isinstance(value, str):
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
def _scan_bounds(grid: CsvGrid) -> RangeBounds | None:
|
||||
col_numbers = [col_to_index(col) for col in grid.col_letters]
|
||||
if grid.row_numbers and grid.col_letters:
|
||||
return RangeBounds(
|
||||
min(grid.row_numbers),
|
||||
min(col_numbers),
|
||||
max(grid.row_numbers),
|
||||
max(col_numbers),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _external_merge_anchors(grid: CsvGrid, layout: dict[str, Any]) -> dict[str, str]:
|
||||
scan_bounds = _scan_bounds(grid)
|
||||
if scan_bounds is None:
|
||||
return {}
|
||||
result = {}
|
||||
for merge_ref in _merged_ranges(layout):
|
||||
try:
|
||||
bounds = parse_range(merge_ref)
|
||||
except ValueError:
|
||||
continue
|
||||
intersects = not (
|
||||
bounds.end_row < scan_bounds.start_row
|
||||
or bounds.start_row > scan_bounds.end_row
|
||||
or bounds.end_col < scan_bounds.start_col
|
||||
or bounds.start_col > scan_bounds.end_col
|
||||
)
|
||||
anchor_in_scan = (
|
||||
scan_bounds.start_row <= bounds.start_row <= scan_bounds.end_row
|
||||
and scan_bounds.start_col <= bounds.start_col <= scan_bounds.end_col
|
||||
)
|
||||
if intersects and not anchor_in_scan:
|
||||
result[merge_ref] = format_range(
|
||||
bounds.start_row, bounds.start_col, bounds.start_row, bounds.start_col
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _has_value(grid: CsvGrid) -> bool:
|
||||
return any(value.strip() for row in grid.values for value in row)
|
||||
|
||||
|
||||
def build_occupancy(
|
||||
grid: CsvGrid,
|
||||
layout: dict[str, Any],
|
||||
*,
|
||||
confirmed_external_merges: set[str] | None = None,
|
||||
) -> set[tuple[int, int]]:
|
||||
col_numbers = [col_to_index(col) for col in grid.col_letters]
|
||||
occupied: set[tuple[int, int]] = set()
|
||||
for row_idx, row_num in enumerate(grid.row_numbers):
|
||||
for col_idx, value in enumerate(grid.values[row_idx]):
|
||||
if value.strip():
|
||||
occupied.add((row_num, col_numbers[col_idx]))
|
||||
|
||||
scan_bounds = _scan_bounds(grid)
|
||||
|
||||
for merge_ref in _merged_ranges(layout):
|
||||
try:
|
||||
bounds = parse_range(merge_ref)
|
||||
except ValueError:
|
||||
continue
|
||||
if scan_bounds is None:
|
||||
continue
|
||||
anchor_in_scan = (
|
||||
scan_bounds.start_row <= bounds.start_row <= scan_bounds.end_row
|
||||
and scan_bounds.start_col <= bounds.start_col <= scan_bounds.end_col
|
||||
)
|
||||
if anchor_in_scan and (bounds.start_row, bounds.start_col) not in occupied:
|
||||
continue
|
||||
if not anchor_in_scan and merge_ref not in (confirmed_external_merges or set()):
|
||||
continue
|
||||
sr = max(bounds.start_row, scan_bounds.start_row)
|
||||
er = min(bounds.end_row, scan_bounds.end_row)
|
||||
sc = max(bounds.start_col, scan_bounds.start_col)
|
||||
ec = min(bounds.end_col, scan_bounds.end_col)
|
||||
if sr > er or sc > ec:
|
||||
continue
|
||||
for row in range(sr, er + 1):
|
||||
for col in range(sc, ec + 1):
|
||||
occupied.add((row, col))
|
||||
return occupied
|
||||
|
||||
|
||||
def _raw_components(
|
||||
occupied: set[tuple[int, int]],
|
||||
*,
|
||||
adjacent_rows: dict[int, set[int]] | None = None,
|
||||
adjacent_cols: dict[int, set[int]] | None = None,
|
||||
) -> list[Component]:
|
||||
remaining = set(occupied)
|
||||
components = []
|
||||
while remaining:
|
||||
start = remaining.pop()
|
||||
queue = deque([start])
|
||||
cells = [start]
|
||||
while queue:
|
||||
row, col = queue.popleft()
|
||||
vertical_rows = adjacent_rows.get(row, set()) if adjacent_rows else {row - 1, row + 1}
|
||||
neighbors = [(neighbor_row, col) for neighbor_row in vertical_rows]
|
||||
# Columns get the same treatment as rows: with --skip-hidden the
|
||||
# returned columns can be non-consecutive (A, C when B is hidden),
|
||||
# so col±1 would split visually adjacent data into two components.
|
||||
horizontal_cols = adjacent_cols.get(col, set()) if adjacent_cols else {col - 1, col + 1}
|
||||
neighbors.extend((row, neighbor_col) for neighbor_col in horizontal_cols)
|
||||
for neighbor in neighbors:
|
||||
if neighbor in remaining:
|
||||
remaining.remove(neighbor)
|
||||
queue.append(neighbor)
|
||||
cells.append(neighbor)
|
||||
rows = [cell[0] for cell in cells]
|
||||
cols = [cell[1] for cell in cells]
|
||||
components.append(
|
||||
Component(
|
||||
RangeBounds(min(rows), min(cols), max(rows), max(cols)),
|
||||
len(cells),
|
||||
)
|
||||
)
|
||||
return components
|
||||
|
||||
|
||||
def _box_gap_mergeable(a: RangeBounds, b: RangeBounds, gap_rows: int, gap_cols: int) -> bool:
|
||||
cols_overlap = not (a.end_col < b.start_col or b.end_col < a.start_col)
|
||||
rows_overlap = not (a.end_row < b.start_row or b.end_row < a.start_row)
|
||||
vertical_gap = max(b.start_row - a.end_row - 1, a.start_row - b.end_row - 1, 0)
|
||||
horizontal_gap = max(b.start_col - a.end_col - 1, a.start_col - b.end_col - 1, 0)
|
||||
return (cols_overlap and vertical_gap <= gap_rows) or (
|
||||
rows_overlap and horizontal_gap <= gap_cols
|
||||
)
|
||||
|
||||
|
||||
def merge_components(
|
||||
components: list[Component], *, gap_rows: int = 1, gap_cols: int = 0
|
||||
) -> list[Component]:
|
||||
merged = components[:]
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
next_components: list[Component] = []
|
||||
used = [False] * len(merged)
|
||||
for i, comp in enumerate(merged):
|
||||
if used[i]:
|
||||
continue
|
||||
current = Component(comp.bounds, comp.occupied_count)
|
||||
used[i] = True
|
||||
for j in range(i + 1, len(merged)):
|
||||
if used[j]:
|
||||
continue
|
||||
other = merged[j]
|
||||
if _box_gap_mergeable(current.bounds, other.bounds, gap_rows, gap_cols):
|
||||
current = Component(
|
||||
range_union(current.bounds, other.bounds),
|
||||
current.occupied_count + other.occupied_count,
|
||||
)
|
||||
used[j] = True
|
||||
changed = True
|
||||
next_components.append(current)
|
||||
merged = next_components
|
||||
return merged
|
||||
|
||||
|
||||
def _row_values(grid: CsvGrid, bounds: RangeBounds, row_num: int) -> list[str]:
|
||||
if row_num not in grid.row_numbers:
|
||||
return []
|
||||
row = grid.values[grid.row_numbers.index(row_num)]
|
||||
values = []
|
||||
for col_idx, col_letter in enumerate(grid.col_letters):
|
||||
col_num = col_to_index(col_letter)
|
||||
if bounds.start_col <= col_num <= bounds.end_col:
|
||||
values.append(row[col_idx] if col_idx < len(row) else "")
|
||||
return values
|
||||
|
||||
|
||||
def header_candidates(grid: CsvGrid, bounds: RangeBounds) -> list[int]:
|
||||
candidates = []
|
||||
for row in range(bounds.start_row, min(bounds.end_row, bounds.start_row + 4) + 1):
|
||||
values = _row_values(grid, bounds, row)
|
||||
non_empty = [value for value in values if value.strip()]
|
||||
if len(non_empty) >= max(1, min(2, bounds.col_count)):
|
||||
candidates.append(row)
|
||||
return candidates
|
||||
|
||||
|
||||
def kind_guess(bounds: RangeBounds, density: float) -> str:
|
||||
if bounds.row_count >= 3 and bounds.col_count >= 2 and density >= 0.25:
|
||||
return "data_table"
|
||||
if bounds.row_count <= 2 or bounds.col_count <= 1:
|
||||
return "note_or_label"
|
||||
if density < 0.25:
|
||||
return "sparse_block"
|
||||
return "summary_block"
|
||||
|
||||
|
||||
def summarize_components(grid: CsvGrid, components: list[Component], min_cells: int) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for idx, comp in enumerate(
|
||||
sorted(components, key=lambda item: (item.bounds.start_row, item.bounds.start_col)),
|
||||
start=1,
|
||||
):
|
||||
if comp.occupied_count < min_cells:
|
||||
continue
|
||||
area = comp.bounds.row_count * comp.bounds.col_count
|
||||
density = comp.occupied_count / area if area else 0
|
||||
samples = []
|
||||
for row in range(comp.bounds.start_row, min(comp.bounds.end_row, comp.bounds.start_row + 2) + 1):
|
||||
samples.append(_row_values(grid, comp.bounds, row))
|
||||
result.append(
|
||||
{
|
||||
"id": f"T{idx}",
|
||||
"range": format_range(
|
||||
comp.bounds.start_row,
|
||||
comp.bounds.start_col,
|
||||
comp.bounds.end_row,
|
||||
comp.bounds.end_col,
|
||||
),
|
||||
"rows": comp.bounds.row_count,
|
||||
"cols": comp.bounds.col_count,
|
||||
"occupied_cells": comp.occupied_count,
|
||||
"density": round(density, 4),
|
||||
"header_candidates": header_candidates(grid, comp.bounds),
|
||||
"kind_guess": kind_guess(comp.bounds, density),
|
||||
"sample": samples,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def detect_subtables(args) -> tuple[dict[str, Any], list[str]]:
|
||||
warnings: list[str] = []
|
||||
workbook = envelope_data(
|
||||
run_sheets(
|
||||
"+workbook-info",
|
||||
url=args.url,
|
||||
spreadsheet_token=args.spreadsheet_token,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
sheet = resolve_target_sheets(
|
||||
workbook,
|
||||
sheet_id=args.sheet_id,
|
||||
sheet_name=args.sheet_name,
|
||||
require_one=True,
|
||||
)[0]
|
||||
sid = sheet_identifier(sheet)
|
||||
title = sheet_title(sheet)
|
||||
locator = sheet_locator(sheet)
|
||||
|
||||
col_count = min(int(sheet.get("column_count") or args.max_scan_cols), args.max_scan_cols)
|
||||
row_count = min(int(sheet.get("row_count") or args.max_scan_rows), args.max_scan_rows)
|
||||
scan_range = args.range or f"A1:{index_to_col(max(1, col_count))}{max(1, row_count)}"
|
||||
if not args.range:
|
||||
if int(sheet.get("column_count") or 0) > args.max_scan_cols:
|
||||
warnings.append(f"scan clipped to first {args.max_scan_cols} columns")
|
||||
if int(sheet.get("row_count") or 0) > args.max_scan_rows:
|
||||
warnings.append(f"scan clipped to first {args.max_scan_rows} rows")
|
||||
|
||||
layout = envelope_data(
|
||||
run_sheets(
|
||||
"+sheet-info",
|
||||
url=args.url,
|
||||
spreadsheet_token=args.spreadsheet_token,
|
||||
**locator,
|
||||
flags={"include": "merges,hidden_rows,hidden_cols"},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
csv_data = envelope_data(
|
||||
run_sheets(
|
||||
"+csv-get",
|
||||
url=args.url,
|
||||
spreadsheet_token=args.spreadsheet_token,
|
||||
**locator,
|
||||
flags={
|
||||
"range": scan_range,
|
||||
"max_chars": args.max_chars,
|
||||
"skip_hidden": True if args.skip_hidden else None,
|
||||
},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
actual_range = str(csv_data.get("actual_range") or scan_range)
|
||||
if csv_data.get("has_more"):
|
||||
raise LarkCliError(
|
||||
f"+csv-get truncated the scan range at {actual_range}; narrow --range before detecting subtables"
|
||||
)
|
||||
|
||||
grid = parse_annotated_csv(
|
||||
csv_data.get("annotated_csv", ""),
|
||||
csv_data.get("col_indices"),
|
||||
csv_data.get("row_indices"),
|
||||
actual_range,
|
||||
)
|
||||
if grid.row_numbers_inferred:
|
||||
warnings.append("CSV row numbers were inferred from the requested range")
|
||||
hidden_rows_raw = layout.get("hidden_rows") or []
|
||||
hidden_row_indexes = {
|
||||
int(value) + 1
|
||||
for value in hidden_rows_raw
|
||||
if isinstance(value, (int, str)) and str(value).isdigit()
|
||||
}
|
||||
hidden_columns_raw = layout.get("hidden_cols") or layout.get("hidden_columns") or []
|
||||
hidden_col_letters = set()
|
||||
for value in hidden_columns_raw if isinstance(hidden_columns_raw, list) else []:
|
||||
if isinstance(value, str) and value.isalpha():
|
||||
hidden_col_letters.add(value.upper())
|
||||
elif isinstance(value, (int, str)) and str(value).isdigit():
|
||||
hidden_col_letters.add(index_to_col(int(value) + 1))
|
||||
hidden_rows = sorted(row for row in grid.row_numbers if row in hidden_row_indexes)
|
||||
hidden_columns = [col for col in grid.col_letters if col.upper() in hidden_col_letters]
|
||||
if hidden_rows or hidden_columns:
|
||||
warnings.append("scan includes hidden rows or columns; pass --skip-hidden to exclude them")
|
||||
confirmed_external_merges = set()
|
||||
external_merge_anchors = list(_external_merge_anchors(grid, layout).items())
|
||||
if len(external_merge_anchors) > MAX_EXTERNAL_MERGE_ANCHOR_CHECKS:
|
||||
warnings.append(
|
||||
f"skipped confirmation for {len(external_merge_anchors) - MAX_EXTERNAL_MERGE_ANCHOR_CHECKS} "
|
||||
f"external merge anchors (limit: {MAX_EXTERNAL_MERGE_ANCHOR_CHECKS})"
|
||||
)
|
||||
for merge_ref, anchor in external_merge_anchors[:MAX_EXTERNAL_MERGE_ANCHOR_CHECKS]:
|
||||
try:
|
||||
anchor_data = envelope_data(
|
||||
run_sheets(
|
||||
"+csv-get",
|
||||
url=args.url,
|
||||
spreadsheet_token=args.spreadsheet_token,
|
||||
**locator,
|
||||
flags={
|
||||
"range": anchor,
|
||||
"max_chars": 1024,
|
||||
"skip_hidden": True if args.skip_hidden else None,
|
||||
},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
anchor_grid = parse_annotated_csv(
|
||||
anchor_data.get("annotated_csv", ""),
|
||||
anchor_data.get("col_indices"),
|
||||
anchor_data.get("row_indices"),
|
||||
anchor,
|
||||
)
|
||||
if _has_value(anchor_grid):
|
||||
confirmed_external_merges.add(merge_ref)
|
||||
except LarkCliError as exc:
|
||||
warnings.append(f"could not confirm merge anchor {anchor}: {exc}")
|
||||
occupied = build_occupancy(
|
||||
grid,
|
||||
layout,
|
||||
confirmed_external_merges=confirmed_external_merges,
|
||||
)
|
||||
adjacent_rows = None
|
||||
adjacent_cols = None
|
||||
if args.skip_hidden:
|
||||
adjacent_rows = {}
|
||||
for previous, current in zip(grid.row_numbers, grid.row_numbers[1:]):
|
||||
adjacent_rows.setdefault(previous, set()).add(current)
|
||||
adjacent_rows.setdefault(current, set()).add(previous)
|
||||
col_numbers = [col_to_index(col) for col in grid.col_letters]
|
||||
adjacent_cols = {}
|
||||
for previous, current in zip(col_numbers, col_numbers[1:]):
|
||||
adjacent_cols.setdefault(previous, set()).add(current)
|
||||
adjacent_cols.setdefault(current, set()).add(previous)
|
||||
raw_components = _raw_components(
|
||||
occupied,
|
||||
adjacent_rows=adjacent_rows,
|
||||
adjacent_cols=adjacent_cols,
|
||||
)
|
||||
if len(raw_components) > args.max_merge_components:
|
||||
warnings.append(
|
||||
f"skipped gap-based component merging for {len(raw_components)} components "
|
||||
f"(limit: {args.max_merge_components})"
|
||||
)
|
||||
components = raw_components
|
||||
else:
|
||||
components = merge_components(
|
||||
raw_components,
|
||||
gap_rows=args.gap_rows,
|
||||
gap_cols=args.gap_cols,
|
||||
)
|
||||
subtables = summarize_components(grid, components, args.min_cells)
|
||||
return {
|
||||
"sheet_id": sid,
|
||||
"sheet": title,
|
||||
"scan_range": scan_range,
|
||||
"actual_range": actual_range,
|
||||
"visibility": {
|
||||
"skip_hidden": args.skip_hidden,
|
||||
"hidden_rows_in_range": hidden_rows,
|
||||
"hidden_columns_in_range": hidden_columns,
|
||||
},
|
||||
"subtables": subtables,
|
||||
}, warnings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
add_spreadsheet_args(parser, require_sheet=True, allow_sheet=True)
|
||||
parser.add_argument("--range")
|
||||
parser.add_argument("--max-scan-rows", type=int, default=5000)
|
||||
parser.add_argument("--max-scan-cols", type=int, default=200)
|
||||
parser.add_argument("--gap-rows", type=int, default=1)
|
||||
parser.add_argument("--gap-cols", type=int, default=0)
|
||||
parser.add_argument("--min-cells", type=int, default=2)
|
||||
parser.add_argument("--max-merge-components", type=int, default=2000)
|
||||
parser.add_argument("--max-chars", type=int, default=25000)
|
||||
parser.add_argument("--skip-hidden", action="store_true")
|
||||
parser.add_argument("--timeout", type=int, default=60)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
data, warnings = detect_subtables(args)
|
||||
except (LarkCliError, ValueError, TypeError) as exc:
|
||||
emit_error(ACTION, str(exc))
|
||||
emit_success(ACTION, data, warnings)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
184
skills/lark-sheets/scripts/lark_inspect_workbook.py
Normal file
184
skills/lark-sheets/scripts/lark_inspect_workbook.py
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Inspect a Lark spreadsheet and emit a compact workbook profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
from lark_sheet_range import index_to_col
|
||||
from lark_sheet_read_cli import (
|
||||
LarkCliError,
|
||||
add_spreadsheet_args,
|
||||
emit_error,
|
||||
emit_success,
|
||||
envelope_data,
|
||||
resolve_target_sheets,
|
||||
run_sheets,
|
||||
sheet_identifier,
|
||||
sheet_locator,
|
||||
sheet_title,
|
||||
)
|
||||
|
||||
ACTION = "inspect_workbook"
|
||||
LAYOUT_INCLUDE = "merges,row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen"
|
||||
|
||||
|
||||
def _sheet_summary(sheet: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"sheet_id": sheet_identifier(sheet),
|
||||
"title": sheet_title(sheet),
|
||||
"index": sheet.get("index"),
|
||||
"row_count": sheet.get("row_count"),
|
||||
"column_count": sheet.get("column_count"),
|
||||
"is_hidden": sheet.get("is_hidden"),
|
||||
"merged_cells_count": sheet.get("merged_cells_count"),
|
||||
"chart_count": sheet.get("chart_count"),
|
||||
"pivot_table_count": sheet.get("pivot_table_count"),
|
||||
"float_image_count": sheet.get("float_image_count"),
|
||||
}
|
||||
|
||||
|
||||
def _list_count(value: Any) -> int:
|
||||
return len(value) if isinstance(value, list) else 0
|
||||
|
||||
|
||||
def _layout_summary(layout: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Retain layout signals without serializing unbounded per-cell metadata."""
|
||||
merges = layout.get("merged_cells") or layout.get("merges") or []
|
||||
groups = layout.get("groups") if isinstance(layout.get("groups"), dict) else {}
|
||||
row_groups = layout.get("row_groups") or groups.get("rows", [])
|
||||
col_groups = layout.get("column_groups") or groups.get("columns", [])
|
||||
return {
|
||||
"merge_count": _list_count(merges),
|
||||
"row_heights_count": _list_count(layout.get("row_heights")),
|
||||
"column_widths_count": _list_count(layout.get("col_widths")),
|
||||
"hidden_rows_count": _list_count(layout.get("hidden_rows")),
|
||||
"hidden_columns_count": _list_count(
|
||||
layout.get("hidden_cols") or layout.get("hidden_columns")
|
||||
),
|
||||
"row_groups_count": _list_count(row_groups),
|
||||
"column_groups_count": _list_count(col_groups),
|
||||
"frozen": layout.get("frozen"),
|
||||
}
|
||||
|
||||
|
||||
def inspect_workbook(args) -> tuple[dict[str, Any], list[str]]:
|
||||
warnings: list[str] = []
|
||||
workbook = envelope_data(
|
||||
run_sheets(
|
||||
"+workbook-info",
|
||||
url=args.url,
|
||||
spreadsheet_token=args.spreadsheet_token,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
target_sheets = resolve_target_sheets(
|
||||
workbook,
|
||||
sheet_id=args.sheet_id,
|
||||
sheet_name=args.sheet_name,
|
||||
)
|
||||
if args.max_sheets < 1:
|
||||
raise LarkCliError("--max-sheets must be at least 1")
|
||||
inspect_count = len(target_sheets)
|
||||
if not args.sheet_id and not args.sheet_name:
|
||||
inspect_count = min(len(target_sheets), args.max_sheets)
|
||||
if inspect_count < len(target_sheets):
|
||||
warnings.append(
|
||||
f"layout and preview skipped for {len(target_sheets) - inspect_count} sheets; "
|
||||
f"pass --sheet-id or --sheet-name to inspect one"
|
||||
)
|
||||
|
||||
profiles = []
|
||||
for position, sheet in enumerate(target_sheets):
|
||||
sid = sheet_identifier(sheet)
|
||||
title = sheet_title(sheet)
|
||||
profile = _sheet_summary(sheet)
|
||||
if position >= inspect_count:
|
||||
profiles.append(profile)
|
||||
continue
|
||||
locator = sheet_locator(sheet)
|
||||
col_count = int(sheet.get("column_count") or args.max_preview_cols)
|
||||
preview_cols = min(col_count, args.max_preview_cols)
|
||||
if col_count > args.max_preview_cols:
|
||||
warnings.append(
|
||||
f"{title or sid}: preview clipped to first {args.max_preview_cols} columns"
|
||||
)
|
||||
end_col = index_to_col(max(1, preview_cols))
|
||||
preview_range = f"A1:{end_col}{args.preview_rows}"
|
||||
|
||||
# Per-sheet, not fail-the-run: this is the first-step pre-flight, and
|
||||
# one unreadable sheet (odd type, transient error, a locator that does
|
||||
# not resolve) must not throw away the summaries already collected for
|
||||
# every other sheet. The basic summary comes from +workbook-info and is
|
||||
# already in hand, so a failure here degrades detail, not correctness —
|
||||
# same call the sibling profile_table downgrades to a warning.
|
||||
try:
|
||||
layout = envelope_data(
|
||||
run_sheets(
|
||||
"+sheet-info",
|
||||
url=args.url,
|
||||
spreadsheet_token=args.spreadsheet_token,
|
||||
**locator,
|
||||
flags={"include": LAYOUT_INCLUDE},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
preview = envelope_data(
|
||||
run_sheets(
|
||||
"+csv-get",
|
||||
url=args.url,
|
||||
spreadsheet_token=args.spreadsheet_token,
|
||||
**locator,
|
||||
flags={"range": preview_range, "max_chars": args.max_chars},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
)
|
||||
except LarkCliError as exc:
|
||||
warnings.append(
|
||||
f"{title or sid}: layout/preview unavailable ({exc}); "
|
||||
"re-read this sheet on its own with --sheet-name, or use +sheet-info / +csv-get directly"
|
||||
)
|
||||
profiles.append(profile)
|
||||
continue
|
||||
if preview.get("has_more"):
|
||||
warnings.append(f"{title or sid}: preview range {preview_range} was truncated")
|
||||
|
||||
profiles.append(
|
||||
{
|
||||
**profile,
|
||||
"layout": _layout_summary(layout),
|
||||
"preview": {
|
||||
"range": preview_range,
|
||||
"current_region": preview.get("current_region"),
|
||||
"row_indices": preview.get("row_indices"),
|
||||
"col_indices": preview.get("col_indices"),
|
||||
"annotated_csv": preview.get("annotated_csv"),
|
||||
"has_more": preview.get("has_more"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"sheet_count": len(target_sheets), "sheets": profiles}, warnings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
add_spreadsheet_args(parser, require_sheet=False, allow_sheet=True)
|
||||
parser.add_argument("--preview-rows", type=int, default=15)
|
||||
parser.add_argument("--max-preview-cols", type=int, default=100)
|
||||
parser.add_argument("--max-chars", type=int, default=8000)
|
||||
parser.add_argument("--max-sheets", type=int, default=3)
|
||||
parser.add_argument("--timeout", type=int, default=60)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
data, warnings = inspect_workbook(args)
|
||||
except (LarkCliError, ValueError, TypeError) as exc:
|
||||
emit_error(ACTION, str(exc))
|
||||
emit_success(ACTION, data, warnings)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user